Skip to content

Commit e9e0afa

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14577-suggest-copies-consolidation
2 parents 424b9c9 + df657d9 commit e9e0afa

11 files changed

Lines changed: 1398 additions & 138 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
fix(objectql): the ADR-0048 install-time namespace gate's refusal carries an ADR-0112 envelope, so `POST /packages` answers 422 instead of 500 (#14474)
7+
8+
`NamespaceConflictError` — raised by `SchemaRegistry.installPackage` when a package's `manifest.namespace` is already owned by an installed package that is not a co-owner of it (ADR-0130 D1) — carried `namespace` / `existingPackageId` / `incomingPackageId` but no `code` and no `status`. It now carries `code: 'NAMESPACE_CONFLICT'` and `status: 422`, the same three-field envelope shape as its sibling `ArtifactObjectNameConflictError` in the same file. The message text is byte-for-byte unchanged: the prose was already correct and specific, and this change adds fields rather than rewriting a sentence.
9+
10+
Why it matters, measured rather than read: unlike its three install-time siblings, this refusal is reachable from a wire. `POST /api/v1/packages` calls `installPackage` with no artifact scope — which this gate, unlike the ADR-0130 D3 object-name one, does not need — and the domain's terminal catch answers `errorFromThrown(e, 500)`. `resolveThrownHttpError` reads `.status` / `.code` off the throw and falls to the caller's fallback when it finds neither. Observed on a booted stack, two installs declaring one namespace:
11+
12+
- before: `500` with `error.code: INTERNAL_ERROR`, carrying the refusal's prose
13+
- after: `422` with `error.code: VALIDATION_ERROR` and `error.declaredCode: NAMESPACE_CONFLICT`
14+
15+
A refusal the platform decided is a client-side conflict was telling operators the server had broken, which invites a retry instead of a rename.
16+
17+
Not narrowed, not widened: no accept-set changes, no export changes, and no ledger registration. `NAMESPACE_CONFLICT` is not an `ErrorCode` member, so the door's narrowing demotes it off `error.code` onto the wire's open `declaredCode` sibling and `error.code` stays the closed member 422 derives.
18+
19+
`@objectstack/runtime` carries the classification row for the new code in the dispatcher error-code vocabulary (verdict `pending-registration`, door `dispatcher` — the measured verdict, not the expected one). That row is the input to a ledger-registration batch in the `packages/spec` lane; registering the code is what ratchets the row back out and what would let `error.code` carry the semantic spelling.

.github/workflows/merge-queue-triage.yml

Lines changed: 128 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -550,13 +550,118 @@ jobs:
550550
};
551551
552552
const ANCHOR_LABEL = 'finding';
553+
// The anchor's IDENTITY label, and the only label the lookup keys on.
554+
// `finding` is a TRIAGE-STATE label: first-touch grading takes it off by
555+
// definition, so a lookup keyed on `finding` stops seeing an anchor the
556+
// moment a human triages it and files a fresh one on the next ejection.
557+
// This label is the machine's, is never graded off, and is what keeps
558+
// the anchor findable for the rest of its life, open or closed.
559+
const ANCHOR_IDENTITY_LABEL = 'queue-flake-anchor';
553560
const MAX_ANCHOR_PAGES = 3;
554561
const anchorNotes = [];
555562
for (const a of aggregated.slice(0, 3)) {
556563
const anchorMarker = `<!-- queue-signature-anchor:${a.key} -->`;
557564
// Stable across refreshes — the victim count lives in the body, so
558565
// a growing count cannot make the anchor unfindable by title.
559566
const title = `Queue-flake anchor: ${a.key}`;
567+
568+
// ── Anchor lookup ─────────────────────────────────────────────
569+
// The anchor's identity is the FILE KEY, carried by the body marker
570+
// with the exact title as a second way in (a body is the one channel
571+
// GitHub is known to rewrite). It is NOT the `finding` label and NOT
572+
// `open`:
573+
// - `finding` comes off the moment the anchor is graded, which is
574+
// what first-touch grading MEANS. Keying on it made a properly
575+
// triaged anchor invisible and turned every further ejection into
576+
// a new anchor.
577+
// - a CLOSED anchor is still evidence about this key: closed as a
578+
// duplicate says the conversation moved; closed on its own merits
579+
// says the next ejection is a REGRESSION, not a continuation.
580+
const isThisAnchor = (i) => !i.pull_request
581+
&& (String(i.body ?? '').includes(anchorMarker) || i.title === title);
582+
const labelNames = (i) => (i.labels ?? []).map((l) => (typeof l === 'string' ? l : l?.name));
583+
584+
// The passes, in order. `firstMatchWins` says whether a pass may stop
585+
// at its first hit: the identity pass may NOT, because the resolution
586+
// order below needs this key's CLOSED anchors as well as its open one.
587+
// `adopts` marks the transitional pass — a bounded scan of OPEN issues
588+
// that finds anchors filed before the identity label existed (a graded
589+
// one carries neither label) and puts the identity label on them, so
590+
// the first pass owns them from the next ejection on.
591+
const ANCHOR_QUERIES = [
592+
{ state: 'all', labels: ANCHOR_IDENTITY_LABEL, firstMatchWins: false, adopts: false },
593+
{ state: 'open', firstMatchWins: true, adopts: true },
594+
];
595+
596+
const candidates = [];
597+
let adopted = null;
598+
let scanComplete = true;
599+
let refusal = null;
600+
for (const [qi, q] of ANCHOR_QUERIES.entries()) {
601+
// A later pass is skipped only once an OPEN anchor is in hand.
602+
// Closed candidates are not enough to stop: a key can have a
603+
// labelled closed duplicate and an UNLABELLED open survivor at the
604+
// same time — that is exactly what a half-migrated key looks like
605+
// — and stopping there would read the survivor as absent.
606+
if (candidates.some((i) => i.state !== 'closed')) break;
607+
const { firstMatchWins = false, adopts = false, ...params } = q;
608+
const found = [];
609+
let complete = true;
610+
try {
611+
for (let p = 1; p <= MAX_ANCHOR_PAGES; p++) {
612+
const res = await github.rest.issues.listForRepo({
613+
owner, repo, ...params,
614+
sort: 'created', direction: 'desc', per_page: 100, page: p,
615+
});
616+
found.push(...res.data.filter(isThisAnchor));
617+
if ((firstMatchWins && found.length > 0) || res.data.length < 100) break;
618+
if (p === MAX_ANCHOR_PAGES) complete = false;
619+
}
620+
} catch (error) {
621+
complete = false;
622+
refusal = describe(error);
623+
}
624+
// Completeness is the IDENTITY pass's argument — it is the bounded,
625+
// label-keyed one. A truncated adoption pass is a missed migration,
626+
// not an unestablished absence, and must not block a first anchor.
627+
if (qi === 0) scanComplete = complete;
628+
if (adopts && found.length > 0) adopted = found[0];
629+
for (const i of found) {
630+
if (!candidates.some((c) => c.number === i.number)) candidates.push(i);
631+
}
632+
}
633+
634+
if (adopted && !labelNames(adopted).includes(ANCHOR_IDENTITY_LABEL)) {
635+
try {
636+
await github.rest.issues.addLabels({
637+
owner, repo, issue_number: adopted.number, labels: [ANCHOR_IDENTITY_LABEL],
638+
});
639+
} catch (error) {
640+
core.warning(`Could not put the identity label on the existing anchor #${adopted.number} for ${a.key} (${describe(error)}); it is refreshed anyway and adoption retries next run.`,
641+
{ title: 'Queue-signature anchor not adopted' });
642+
}
643+
}
644+
645+
// Resolution order. OPEN beats closed, and the OLDEST open anchor
646+
// beats a newer one: that is the issue the duplicates were closed
647+
// against.
648+
const openAnchors = candidates.filter((i) => i.state !== 'closed')
649+
.sort((x, y) => x.number - y.number);
650+
const closedAnchors = candidates.filter((i) => i.state === 'closed')
651+
.sort((x, y) => y.number - x.number);
652+
const existing = openAnchors[0] ?? null;
653+
const closedAsDuplicate = !existing && closedAnchors[0]?.state_reason === 'duplicate'
654+
? closedAnchors[0]
655+
: null;
656+
// A closed anchor that was NOT closed as a duplicate is a RESOLVED
657+
// one: the flake was answered once and this key is ejecting PRs
658+
// again. A new anchor is legitimate there, but it has to say which
659+
// issue it regressed from or the previous answer is lost.
660+
const resolvedBefore = closedAnchors.find((i) => i.state_reason !== 'duplicate') ?? null;
661+
const priorAnchor = resolvedBefore && (!existing || existing.number > resolvedBefore.number)
662+
? resolvedBefore
663+
: null;
664+
560665
const prRows = [...a.prs.entries()].sort((x, y) => x[0] - y[0]).map(([pr, runs]) => {
561666
const s = a.stackOf.get(pr);
562667
const cell = !s || s.size < 2
@@ -572,6 +677,13 @@ jobs:
572677
'that conversation; it is refreshed by the merge-queue-triage workflow on every',
573678
'further ejection.',
574679
'',
680+
...(priorAnchor
681+
? [
682+
`⚠️ 同签名的上一个汇总 issue #${priorAnchor.number} 已经关闭(不是作为重复关闭的),`,
683+
'之后这个文件又开始弹出 PR ⇒ 这是一次**回归**,上一轮的结论在那张 issue 里。',
684+
'',
685+
]
686+
: []),
575687
'| PR | stack | queue build |',
576688
'|---|---|---|',
577689
...prRows,
@@ -616,29 +728,6 @@ jobs:
616728
anchorMarker,
617729
].join('\n');
618730
619-
let existing = null;
620-
let scanComplete = true;
621-
let refusal = null;
622-
try {
623-
for (let p = 1; p <= MAX_ANCHOR_PAGES; p++) {
624-
const res = await github.rest.issues.listForRepo({
625-
owner, repo, state: 'open', labels: ANCHOR_LABEL,
626-
sort: 'created', direction: 'desc', per_page: 100, page: p,
627-
});
628-
// Identity is the body marker; the exact title is a second
629-
// way in, because a body is the one channel GitHub is known
630-
// to rewrite and an anchor that cannot be found is an anchor
631-
// that gets duplicated.
632-
existing = res.data.find((i) => !i.pull_request
633-
&& (String(i.body ?? '').includes(anchorMarker) || i.title === title)) ?? null;
634-
if (existing || res.data.length < 100) break;
635-
if (p === MAX_ANCHOR_PAGES) scanComplete = false;
636-
}
637-
} catch (error) {
638-
scanComplete = false;
639-
refusal = describe(error);
640-
}
641-
642731
if (existing) {
643732
try {
644733
await github.rest.issues.update({
@@ -664,11 +753,25 @@ jobs:
664753
continue;
665754
}
666755
756+
if (closedAsDuplicate) {
757+
// The newest anchor for this key was closed AS A DUPLICATE and no
758+
// open anchor is left. Filing a fresh one here is exactly what
759+
// feeds the loop — it would be closed as a duplicate in turn.
760+
// GitHub's issue payload does not carry a duplicate's TARGET (only
761+
// `state_reason: duplicate`), so the canonical cannot be followed
762+
// from here; this says where the conversation was sent instead of
763+
// guessing at a new home for it.
764+
anchorNotes.push(`- \`${a.key}\` — ${victimPhrase(a)}。⚠️ 同签名的汇总 issue #${closedAsDuplicate.number} 已作为**重复**关闭,且没有仍然打开的同签名汇总 issue ⇒ 本次不新建(新建只会再被判重)。请到它指向的那张 issue 上谈,或重开 #${closedAsDuplicate.number}。`);
765+
core.warning(`The newest anchor for ${a.key} (#${closedAsDuplicate.number}) was closed as a duplicate and no open anchor is left; not filing another one.`,
766+
{ title: 'Queue-signature anchor not created' });
767+
continue;
768+
}
769+
667770
try {
668771
const created = await github.rest.issues.create({
669-
owner, repo, title, body, labels: [ANCHOR_LABEL],
772+
owner, repo, title, body, labels: [ANCHOR_LABEL, ANCHOR_IDENTITY_LABEL],
670773
});
671-
anchorNotes.push(`- \`${a.key}\` — ${victimPhrase(a)}。汇总 issue:#${created.data.number}(新建)`);
774+
anchorNotes.push(`- \`${a.key}\` — ${victimPhrase(a)}。汇总 issue:#${created.data.number}(${priorAnchor ? `新建,回归自已关闭的 #${priorAnchor.number}` : '新建'})`);
672775
} catch (error) {
673776
anchorNotes.push(`- \`${a.key}\` — ${victimPhrase(a)}。⚠️ 汇总 issue 新建失败(${describe(error)}),上面的名单就是全部事实。`);
674777
core.warning(`Could not file the anchor issue for ${a.key} (${describe(error)}).`,

packages/objectql/src/registry-artifact-co-ownership.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,13 @@ describe('ADR-0130 D1 + D3 — the gate relaxation and the object-name check are
199199
expect(err.namespace).toBe('crm');
200200
expect(err.existingPackageId).toBe('com.acme.crm');
201201
expect(err.incomingPackageId).toBe('com.acme.crm.billing');
202+
// [#14474] The ADR-0112 envelope, asserted the same way this file already
203+
// asserts its D3 sibling's (`caught?.code` / `caught?.status` below). The
204+
// instance check above is NOT a substitute: it stayed green through every
205+
// year this class carried no `code` and no `status` at all, which is
206+
// precisely how the refusal reached `POST /api/v1/packages` as a 500.
207+
expect((refused as Envelope).code).toBe('NAMESPACE_CONFLICT');
208+
expect((refused as Envelope).status).toBe(422);
202209
// Nothing half-applied: the refused package is not recorded.
203210
expect(engineOf(kernel).registry.getPackage('com.acme.crm.billing')).toBeUndefined();
204211
});

packages/objectql/src/registry-namespace-install-gate.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,29 @@ describe('SchemaRegistry — namespace install gate (ADR-0048 Phase 1)', () => {
5656
expect(registry.getNamespaceOwners('crm')).toEqual(['com.acme.crm']);
5757
});
5858

59+
it('carries the ADR-0112 envelope: code NAMESPACE_CONFLICT + status 422', () => {
60+
// [#14474] The assertion the instance checks above cannot make, and the
61+
// reason this defect survived: `toThrowError(NamespaceConflictError)` and
62+
// `toBeInstanceOf(NamespaceConflictError)` are TRUE of a class carrying no
63+
// `code` and no `status`, so both stayed green while `POST /api/v1/packages`
64+
// answered this refusal as `500 INTERNAL_ERROR`. Measured on a booted stack
65+
// before the envelope landed; `422` with `declaredCode: NAMESPACE_CONFLICT`
66+
// after it. `resolveThrownHttpError` reads exactly these two fields off the
67+
// throw, so they are what the door's answer is MADE of — asserting the
68+
// class instead asserts something the wire never sees.
69+
registry.installPackage(manifest('com.acme.crm', 'crm') as any);
70+
let caught: (Error & { code?: string; status?: number }) | undefined;
71+
try {
72+
registry.installPackage(manifest('com.beta.crm', 'crm') as any);
73+
} catch (e) { caught = e as Error & { code?: string; status?: number }; }
74+
75+
expect(caught?.code).toBe('NAMESPACE_CONFLICT');
76+
expect(caught?.status).toBe(422);
77+
// The prose is unchanged by the envelope — this card added fields, it did
78+
// not rewrite a sentence. Its first clause is what an operator reads.
79+
expect(caught?.message).toContain('Namespace conflict: namespace "crm"');
80+
});
81+
5982
it('allows the same package to reinstall/reload its own namespace', () => {
6083
registry.installPackage(manifest('com.acme.crm', 'crm') as any);
6184
expect(() =>

packages/objectql/src/registry.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,10 +1252,29 @@ function toRecordManifest(manifest: ObjectStackManifest): ObjectStackManifest {
12521252
* install up front with an actionable error, instead of letting a half-applied
12531253
* install blow up later at table creation. Shareable platform namespaces
12541254
* (`base`/`system`/`sys`) are exempt.
1255+
*
1256+
* [#14474] Carries the ADR-0112 envelope (`code` + `status`), like its sibling
1257+
* {@link ArtifactObjectNameConflictError} below. Unlike that sibling, this
1258+
* refusal IS reachable from a wire: `POST /api/v1/packages`
1259+
* (`packages/runtime/src/domains/packages.ts`) calls `installPackage` with no
1260+
* artifact scope — which this gate, unlike the D3 object-name one, does not
1261+
* need — and the domain's terminal catch answers `errorFromThrown(e, 500)`.
1262+
* `resolveThrownHttpError` reads `.status`/`.code` off the throw, so with no
1263+
* envelope the door fell through to that `500` fallback. Measured on a booted
1264+
* stack before this change: `500 INTERNAL_ERROR` carrying this refusal's prose,
1265+
* which tells an operator "the server broke" when the truth is "your package's
1266+
* namespace is already taken" — it invites a retry instead of a rename. With
1267+
* the envelope the same door answers `422`. The message is unchanged: it was
1268+
* already correct and specific.
12551269
*/
12561270
export class NamespaceConflictError extends Error {
1271+
readonly code = 'NAMESPACE_CONFLICT';
1272+
readonly status = 422;
1273+
/** The namespace both packages claim. */
12571274
readonly namespace: string;
1275+
/** The installed package that already owns the namespace. */
12581276
readonly existingPackageId: string;
1277+
/** The package whose install this refusal stopped. */
12591278
readonly incomingPackageId: string;
12601279

12611280
constructor(namespace: string, existingPackageId: string, incomingPackageId: string) {

0 commit comments

Comments
 (0)