diff --git a/.changeset/unanswerable-target-refusal-opens-with-prose.md b/.changeset/unanswerable-target-refusal-opens-with-prose.md new file mode 100644 index 0000000000..394ad26ac8 --- /dev/null +++ b/.changeset/unanswerable-target-refusal-opens-with-prose.md @@ -0,0 +1,18 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +`findReferencesToMeta`'s unanswerable-target refusal now opens with prose instead of a machine-shaped `[unanswerable_target]` tag that nothing read. + +``` +before 501 {"error":{"code":"NOT_IMPLEMENTED","message":"[unanswerable_target] References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}} +after 501 {"error":{"code":"NOT_IMPLEMENTED","message":"References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}} +``` + +Nothing else moves: same `501`, same `NOT_IMPLEMENTED`, same envelope position, and the prescriptive sentence ADR-0110 D3 requires is untouched. Callers branch on `code`, which is unchanged; only the human-facing sentence is shorter. + +Why the tag was wrong here specifically. This producer writes a bracketed tag on many refusals, and every other one is the lowercase restatement of that throw's own declared `code` — `[item_locked]` with `ITEM_LOCKED`, `[no_draft]` with `NO_DRAFT`, `[invalid_request]` with `INVALID_REQUEST`. Measured across the two producer files, 30 of the 31 tagged throw sites that declare a code restate it that way. This refusal declares `NOT_IMPLEMENTED`, so its tag was the sole exception: it named a token the envelope carries on no axis, and a repo-wide search finds no parser, no switch, no assertion and no doc that reads it. Per the ruling behind the `/data` door's `FORBIDDEN:` prefix removal, `error` is human language and `code` is the machine token. + +It became worth fixing when the `/meta/:type/:name/references` door started relaying the producer's prose verbatim: before that the whole sentence was replaced by `Internal server error` and the tag reached nobody, and after it the tag was the first thing an operator read on the screen where they decide whether to delete something. The `@objectstack/rest` entry in this release quotes the pre-removal sentence in its example; this entry is the later word on that wire text. + +The absence is now pinned in `protocol.reference-target-unanswerable.test.ts` — nothing pinned the tag, so without a pin nothing would have pinned its removal either. diff --git a/packages/metadata-protocol/src/protocol.reference-target-unanswerable.test.ts b/packages/metadata-protocol/src/protocol.reference-target-unanswerable.test.ts index f85b646a8f..577cdac7ad 100644 --- a/packages/metadata-protocol/src/protocol.reference-target-unanswerable.test.ts +++ b/packages/metadata-protocol/src/protocol.reference-target-unanswerable.test.ts @@ -101,6 +101,34 @@ describe('[#9327] a `field` TARGET is refused, not cleared', () => { expect(err.message).toContain('account.owner'); }); + it('THE PIN: the message opens with PROSE — no machine-shaped bracketed tag', async () => { + // [#16145] Since #15685 this sentence reaches the operator VERBATIM, so + // its first characters are the first thing they read. Every OTHER bracketed + // tag this producer writes (`[item_locked]`, `[no_draft]`, `[invalid_request]`, + // …) is a lowercase restatement of that throw's OWN declared `code`, so the + // token also rides the `code` axis. This refusal's code is `NOT_IMPLEMENTED`, + // so `[unanswerable_target]` restated nothing the envelope carried and nothing + // read it — while #12975 (2026-08-29) rules that `error` is HUMAN LANGUAGE and + // `code` is the MACHINE TOKEN. + // + // Pinned as an ABSENCE because nothing else is: with the tag deleted, no other + // assertion in this repo would notice it coming back. + const protocol = protocolWith({}); + + const err = await expectUnanswerableRefusal( + () => protocol.findReferencesToMeta({ type: 'field', name: 'account.owner' }), + ); + + expect(err.message).not.toContain('[unanswerable_target]'); + expect( + err.message.startsWith('['), + `the message opens with a bracketed tag: ${err.message.slice(0, 48)}`, + ).toBe(false); + // …and the prose it opens with INSTEAD is asserted here too, so this pin + // cannot go green by the message becoming empty or generic. + expect(err.message).toMatch(/^References to a 'field' item cannot be computed\./); + }); + it('the refusal is PRESCRIPTIVE — it names the answerable question (ADR-0110 D3)', async () => { // A refusal that only says "no" moves the operator from a false // clearance to a dead end. A field's dependents ARE reachable, through diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8e7ff8382d..f11ea95ceb 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -21414,10 +21414,21 @@ export class ObjectStackProtocolImplementation implements // question, because a field's dependents ARE reachable — through the // object that owns it, which is where a field is authored and where the // reference graph has real edges. + // + // ⛔ And it opens with NO bracketed tag. The `[item_locked]`-style tags + // this file writes elsewhere are lowercase restatements of the throw's OWN + // declared `code`, so the wire carries the same token on the `code` axis; + // this refusal's code is `NOT_IMPLEMENTED`, so an `[unanswerable_target]` + // opener restated nothing the envelope carries and nothing ever read it. + // #12975 (2026-08-29) rules that `error` is HUMAN LANGUAGE while `code` is + // the MACHINE TOKEN, and since #15685 this prose reaches the operator + // VERBATIM — so the tag was the first thing they read. What separates this + // refusal from the route's other 501 is the sentence itself, not a tag. + // Its absence is pinned by `protocol.reference-target-unanswerable.test.ts`. if (REFERENCE_SITES.unanswerableTargetTypes.includes(singularTarget)) { const owner = targetName.includes('.') ? targetName.slice(0, targetName.indexOf('.')) : ''; const err = new Error( - `[unanswerable_target] References to a '${singularTarget}' item cannot be computed. ` + `References to a '${singularTarget}' item cannot be computed. ` + `A '${singularTarget}' is addressed by the composite key '.' ` + `(here '${targetName}'), while every metadata property that names a field holds the ` + `BARE field name — so no reference site can ever match this key and an empty answer `