Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/unanswerable-target-refusal-opens-with-prose.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('.')) : '<object>';
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 '<object>.<field>' `
+ `(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 `
Expand Down
Loading