fix(metadata-protocol): refusal messages stop restating their own code in a bracketed opener - #19683
Conversation
…hrow's own code Every refusal `ObjectStackProtocolImplementation` and `SysMetadataRepository` raise opened with a lowercase `[tag]` that is the restatement of the `code` the same throw declares. `withoutDeclaredCodePrefix` strips only `CODE: ` casing and separator, so the bracketed spelling was never stripped and reached the caller in `error.message` — the duplication the 2026-08-29 ruling removes, because the same fact already rides the `code` axis. 37 openers removed across the two files; in-file prose that named a tag now names the code, which is the axis that still carries it. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
…ence The per-door pins asserted the bracketed opener as part of the refusal text; they now assert the prose that opens instead, so each still proves the declared 4xx sentence is quoted verbatim. Mock refusals that fabricated a producer spelling were repointed too — a fixture that no longer resembles its subject is how the idiom spreads. Adds `protocol.bracketed-refusal-opener-absence.test.ts`: nothing else notices a tag coming back, since a newly-written refusal reds no existing pin. Scans both producers for the shape and carries a `new Error(` floor, so a scan that matches nothing reds instead of passing for free. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
`assertOverlayAllowed`'s shared emitter opened its message with `[${code}]`,
built from the same variable it assigns to `err.code` three lines down. Same
family, most redundant member, and invisible to every grep for a literal tag —
which is why the card's inventory did not carry it.
The absence pin now detects the interpolated spelling as well, and declares the
two bracketed vocabularies that are NOT this family (the `[Protocol]` and
`[SysMetadataRepository]` logger prefixes) by name rather than by heuristic.
Pins that read the code off the PROSE now read it off `err.code`: three of them
were only ever green because the tag happened to spell the token.
Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
…ast stale prose `packages/runtime`'s two publish-drafts pins asserted the bracketed opener on the seed-apply refusal, which `metadata-protocol` produces — the only two cross-package assertions the change moves. They now assert the sentence; the class still rides `code`. Also corrects three in-file comments that still spelled a tag the producer no longer writes. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
…acketed-tag-refusal-openers
📓 Docs Drift CheckThis PR changes 2 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin abf6d9455143e9f7b0014253657634fd37f2402b && git checkout abf6d9455143e9f7b0014253657634fd37f2402b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e2c55edb1ecde1136c1b137f00d021fd8a840b25 5e294644b26155abef6c6595ec0e80c68bb40537 && git checkout -B drift-repro e2c55edb1ecde1136c1b137f00d021fd8a840b25 && git merge --no-ff 5e294644b26155abef6c6595ec0e80c68bb40537
node scripts/docs-audit/affected-docs.mjs --json e2c55edb1ecde1136c1b137f00d021fd8a840b25
|
Removing the bracketed openers made four published carriers wrong. The three that are editable are repaired here, because a change that makes a published claim false owns the repair. - `ProtocolSchema`'s promotion `describe()` said the lookup answers 404 `[no_draft]`; it now names `NO_DRAFT`, the axis that still carries it. - `content/docs/references/api/protocol.mdx` regenerated from that source via `check:generated --fix` — AUTO-GEN, never hand-edited. One line moved. - The error catalog's two documented `INVALID_REQUEST` payloads showed a `message` opening with the tag beside a `code` field already carrying the token; they now show what the platform emits. ⛔ The three carriers in `content/docs/releases/v17/` are deliberately left untouched: release pages record what shipped, and a code PR does not edit them. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
…acketed-tag-refusal-openers
CI triage —
|
| # | Test | Pinned | Now emitted |
|---|---|---|---|
| 1 | protocol-commit-history.test.ts:659 |
[NOT_OVERRIDABLE] opener |
opener absent, guidance appended |
| 2 | protocol-commit-history.test.ts:855 |
same | same |
| 3 | protocol-meta-types-rich.test.ts:133 |
/not_(overridable|creatable)/ |
Metadata type 'agent' is code-only: … |
| 4 | protocol-meta.test.ts:634 |
/invalid_metadata/ |
view/bad_view failed spec validation: … |
| 5 | protocol-object-overlay-layer.test.ts:573 |
object_overlay_package_mismatch |
[Protocol] Failed to hydrate object/myapp_invoice: … |
| 6 | protocol-writepath-object-ownership.test.ts:321 |
/not_overridable/ |
Metadata item 'object/myapp_invoice' is provided by a code package … |
All paths are under packages/objectql/src/.
These are two families, not one. (1) and (2) pin the uppercase bracketed opener that #16245 exists to retire — updating those assertions is in scope. (3)–(6) pin a lowercase snake token, which is a different carrier from the uppercase bracket; if those messages have lost every machine-readable code token as collateral, the correction belongs in packages/metadata-protocol/src/protocol.ts, not in the assertions. Case 4 is the sharpest: line 632's expect(caught.code).toBe('INVALID_METADATA') still passes, so only the message text changed. Deciding that fork is the next step here; the six assertions will not be blanket-rewritten to match whatever the code currently emits.
Cross-lane note. packages/objectql is domain:engine, outside this card's domain:spec lane. Per the seat charter the card does not move and the fix stays in this PR, with the widened file face declared on the card. Separately, packages/spec/src/api/protocol.zod.ts (+1/-1 here) is also held by open PR #19600 — that overlap is left to the merge queue, not resolved in either PR.
The failed check has not been re-run: a re-run is only warranted for a failure that is not this PR's, and this one is.
Generated by Claude Code
…he boot log its own code Six objectql assertions read the retired opener. Five are the same family as the producer change and move to the envelope, which is strictly stronger than a regex over prose: `code` plus `status` instead of a lowercase token that only ever appeared inside the bracket. The sixth is NOT that. `protocol-object-overlay-layer.test.ts` asserts captured `console.warn` output, and a log line has no envelope beside it — the `code` axis a caller reads does not exist there, so removing the opener took the only machine-readable token with it and left nothing to fall back on. Fixed at the PRODUCER's log site rather than in the assertion: the boot warning now prints the declared code itself, as the sibling branch twelve lines above already does. The caller-facing message stays prose. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
… owns
`os meta delete` carries the refusal on two surfaces and they are not the same
surface, so the two assertions do not move the same way.
stdout is the HUMAN one: `printError` prints the message and nothing else, so
the token never belonged there. That assertion now reads the sentence, and the
structural controls already in the test — exit code, the reset request's
parentVersion, the `if-match` header — are what keep it from degrading into
"something went wrong".
`--format json` is the MACHINE one, and it already carries the token on its own
axis. Measured rather than assumed, by dumping the real envelope:
{ success: false, error: '…has been modified…',
code: 'METADATA_CONFLICT', httpStatus: 409 }
So the assertion reads `code` and `httpStatus`, which a prose match over `error`
only ever approximated. ⛔ No producer change was needed and the opener was not
re-added: `errorCodeFields` has carried this since #13347.
Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
…acketed-tag-refusal-openers
Contract reviewServed-tier: 108/108 Rendered by an isolated review subagent, adopted by the seat after per-request transcript verification ( ① Derived judgments
② Semver level
③ Boundary flagsNo pin was weakened — all 23 repointed assertions were checked one by one; seven moved from a prose token to ⛔ Two owed changes, both inside the reviewed face and both one line:
Recorded, ⛔ not blocking: Not measured: whether Implemented-by: VERDICT: FAIL Generated by Claude Code |
…t it changed
Two corrections from the at-tier review, one line each.
`plugin-rest-api.zod.ts` still described `publishMetaItem` as answering
404 `[no_draft]` while `protocol.zod.ts` now says `NO_DRAFT`. Both ship in the
SAME `@objectstack/spec` minor, twice over — as published `.zod.ts` source and
in `dist/api/index.{js,mjs}` — so one release would have carried two
contradictory descriptions of one door. That is not a follow-up; it is a defect
in this change. ⛔ Nothing regenerates from it: all 15 spec artifacts re-checked
clean, `content/docs`, `json-schema` and `api-surface` included.
The changeset headline claimed refusal messages no longer open with a bracketed
tag restating their own code. Measured in the built package, that is false: the
third producer, `runtime-authoring-gate.ts`, still throws `[invalid_metadata]`
and the opener is present 1/1 in both `dist/index.js` and `dist/index.cjs`. The
headline is now scoped to the two producers this change actually touched.
⛔ The removal is NOT extended into that third producer — beyond triage's
boundary, and the review does not ask for it.
`metadata_conflict` on the same line is deliberately left lowercase: it is the
shared spelling in `protocol.zod.ts` too, so changing it here would create the
inconsistency this commit is removing.
Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
…acketed-tag-refusal-openers
Discharges the `os-regen-pending` deferral the merge driver recorded. The merge brought an `enableOnInstall` description that git's textual merge dropped from the generated page; only a regeneration on the committed merge restores it, which is why the driver defers instead of trusting the text merge. Not this card's content — regenerated, never hand-edited. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 64/64 Rendered by an isolated review subagent on the head above, which supersedes the FAIL record #19683 (comment) on ① Derived judgments
② Semver level
③ Boundary flagsDev
CI at this head: 42 check runs, all completed, every one Nothing in this record derives from a dispatch instruction; the prior record was used to choose what to re-measure, not what to conclude. Implemented-by: VERDICT: PASS Tier control — measured by the seat, not reported by the reviewerThe subagent self-reported
So the reviewer was served at tier for every request it made, and the The reviewer also flagged, for anyone running it from the shared checkout: Generated by Claude Code |
Fixes #16245
Clause-②: yes
Every refusal
ObjectStackProtocolImplementationandSysMetadataRepositoryraised opened with a bracketed tag that restates thecodethe same throw declares. The 2026-08-29 maintainer ruling is ONE envelope semantics —erroris HUMAN LANGUAGE,codeis the MACHINE TOKEN — and a prefix is removed because the same fact already rides thecodeaxis. These met that condition by construction, so they are gone;codeandstatusare untouched on every one of them.27 files, 116 derived gate families, head
5e294644b2.What was measured, not assumed
The inventory is 38, not the 30 in the card's title. Re-derived by pairing every throw site in the two named files with the
codeit declares, rather than by grepping literal tags:[tag]message openerscodeThe card measured 31 tagged sites / 30 restatements on 2026-09-06; the tree moved 16 days, and
[unanswerable_target]— the one mismatch, which #16145 already removed — is correctly absent from this re-derivation. Firing controls on the same command and scope: a nonexistent tag returns 0,unanswerable_targetreturns only its pin and a comment, and the pattern finds the idiom in 20+ files repo-wide.The 38th is the one no grep could find.
assertOverlayAllowed's shared emitter opened its message with an interpolated[${code}], built from the same variable it assigns toerr.codethree lines down — the most redundant member of the family and invisible to every literal-tag probe, which is why it is absent from the card's inventory.Reachability re-verified.
withoutDeclaredCodePrefixreturns the message unchanged unlessmessage.startsWith(error.code)AND a colon separator follows.[no_draft]starts with neither, so it was never stripped and reached the caller inerror.message. This is a published-output change, which is whyClause-②isyes.Nothing consumed the tag — the open question triage left on the card. The only consumers found anywhere are strippers:
@object-ui/react'sextractWriteErrorMessageand twoplugin-detailcall sites each remove a leading bracketed prefix before showing the sentence to a user, beside theSCREAMING_SNAKE:strip. They independently confirm the tag was arriving, and they cannot break on its absence — the regex simply matches nothing.Two bracketed vocabularies are deliberately kept, because neither restates a declared code: the
path [zod code]locators inside a validation headline, and the[rule]locators the author-time gate composes. The[Protocol]and[SysMetadataRepository]logger prefixes are likewise untouched and declared by name in the new pin.The published docs this change falsified are repaired here
Removing the openers made documented payloads wrong. Repairing what this round makes false is part of the round.
content/docs/api/error-catalog.mdx(:609,:660) — both documentedINVALID_REQUESTpayloads showed amessageopening with the tag beside acodefield already carrying the token. Hand-written page, edited directly.packages/spec/src/api/protocol.zod.ts:815— the promotiondescribe()said the lookup answers 404[no_draft]; it now namesNO_DRAFT, the axis that still carries it.content/docs/references/api/protocol.mdx— AUTO-GENERATED from that source, so it was ⛔ never hand-edited: regenerated withcheck:generated --fix, which proved exactly 1 of 15 artifacts stale and rewrote only that one. The page moved exactly 1 line.⛔ The three carriers in
content/docs/releases/v17/are deliberately left alone. Release notes record what shipped in the version they document; v17.1 and v17.2 did emit those tags, so those pages are historically accurate and are not a defect to repair.The blast radius, closed
A message-text change breaks every pin asserting that text. Thirteen pins in
packages/metadata-protocol, two inpackages/runtime, six inpackages/objectql, and two inpackages/cli.@objectstack/cli was scheduled but never reached. A green shard is only evidence about what it reached — the run that finally reported11 of 11 scheduled package(s) reported, 0 never reachedis the one that measured the whole set. The consumer scan is therefore repo-wide and not CI-led: the only remaining assertion on a removed opener anywhere in the tree is apackages/resttest that constructs its own error, which is fixture drift, not a consumer.The six objectql assertions split into two families, and only one is this card's to retire:
code+status), which is strictly stronger than what they had — two were barerejects.toThrow(/regex/), which a plainErrorwould have satisfied. In each case the pre-change producer string carried the lowercase token ONLY inside the brackets, with zero occurrences elsewhere in the message.protocol-object-overlay-layer.test.tsasserts capturedconsole.warnoutput, and a log line has no envelope beside it — thecodeaxis a caller reads does not exist there, so removing the opener took the only machine-readable token with it and left nothing to fall back on. Genuine collateral. ⇒ Fixed at the producer's log site, not in the assertion: the boot warning now prints the declared code itself, as the sibling branch twelve lines above already does. ⛔ The opener was NOT restored at the producer — that would reintroduce the defect for every caller in order to serve one log site.Three pins elsewhere were only ever green because the tag happened to spell the token; they now read
err.code.os meta delete— two surfaces, two different answersThe CLI carries this refusal on both a human and a machine surface, and the two assertions do not move the same way.
printErrorprints the message and nothing else, so the token never belonged there. That assertion now reads the sentence, and the structural controls already in the test — exit code, the reset request'sparentVersion, theif-matchheader — are what keep it from degrading into "something went wrong".--format jsonis the machine surface, and it already carries the token. Measured rather than assumed, by dumping the real envelope through a temporary probe with a proven restore:So the assertion reads
codeandhttpStatus, which a prose match overerroronly ever approximated. ⛔ No producer change was needed and the opener was not re-added —errorCodeFieldshas carried this since #13347. Had the envelope carried nocode, that would have been a real regression on a documented machine-readable surface and the fix would have belonged at the producer, exactly as it did for the boot log line above.Tests
pnpm --filter @objectstack/metadata-protocol test— 186 files / 2650 tests pass, 3 files and 19 tests skipped.typecheckexit 0, and--listFilesshows all 189 test files inside the tsc program, so the new pin is type-checked.packages/objectql— the 5 files, 159 tests, exit 0;typecheckexit 0.packages/runtimeandpackages/resttargeted — exit 0.pnpm lint— the whole repo,eslint . --no-inline-config, exit 0, zero findings. No narrowing was used.dispatch-gates --ranreconciliation).check:dual-build-cjs-loadsandcheck:type-check-debtfirst answeredPREREQUISITE NOT MET(exit 3, explicitly not a pass); the whole-workspace build they ask for was paid and both then read 0.Two reverse verifications, each with on-disk proof and a proven restore:
ce0ae6aafeb6to734afa23b233). The new absence pin went red, namingprotocol.ts:8204. Restored; worktree blob equals the HEAD blob andgit diff HEADempty.dist, the mutation was rebuilt and confirmed present in bothdist/index.jsanddist/index.cjsbefore any verdict was read — an unbuilt ablation here would have stayed green and vouched for nothing. The test then went red. Restored, rebuilt, marker count indistback to 0, whole-treegit status --porcelainempty.Changeset
minoron both@objectstack/metadata-protocoland@objectstack/spec, measured rather than pattern-matched. Both are public, non-private packages. metadata-protocol shipsfiles: ["dist", …]and the new message bytes appear twice each indist/index.jsanddist/index.cjs, while both controls (the old literal opener, the interpolated opener) read 0. spec shipsdistandsrc/**/*.zod.ts, and the new describe bytes appear indist/api/index.jsand the browser builds.content/docs/**is shipped by no package, so the mdx edits publish nothing on their own.Clause-②: yestakes at leastminor; no(narrowing)arm, because nothing an author can write is removed, renamed or narrowed.Acceptance notes
Noted, not filed, and ⛔ not ridden along:
packages/metadata-protocol/src/runtime-authoring-gate.tsstill writes[invalid_metadata]in front ofcode = 'INVALID_METADATA'/status = 422. Same package, same family, same ruling, third producer — outside the two files this card named, and it reachesdist/index.js. The nearest same-class remainder and the obvious next increment.packages/rest/src/rest-server.tsraises its own[invalid_request]opener, andpackages/runtime/src/domains/packages.tsits own[writable_package_required]one. The idiom is repo-wide well beyond this card's two files.packages/spec/src/api/protocol.zod.tsWAS taken, not merely flagged — the describe correction at:815is in this diff, and triage's boundary comment5571629394names exactly that correction as part of the deliverable, which is what makesFixes #16245honest.packages/spec/src/api/plugin-rest-api.zod.ts:898was taken too, on the at-tier review's reading: it described the samepublishMetaItemdoor as answering 404[no_draft]whileprotocol.zod.tsnow saysNO_DRAFT, and BOTH ship in this same@objectstack/specminor — twice over, as published.zod.tssource and indist/api/index.{js,mjs}. One release carrying two contradictory descriptions of one door is a defect in this change, not a follow-up. Nothing regenerates from it (all 15 spec artifacts re-checked clean).metadata_conflicton that line stays lowercase deliberately — it is the shared spelling inprotocol.zod.tstoo, so "correcting" it would introduce the very inconsistency this removes.settingsis a platform group, not a per-app one (#15178) #19600 also holdsprotocol.zod.ts, at@@ -27,7and@@ -3167,9, while this diff's single hunk is@@ -812,7— roughly 2,350 lines clear, so it is not a region collision. It goes to the merge queue, ⛔ never hand-sequenced.git grepover the whole tracked tree at this head, with a dark control ([no_draft_xyzzy]) returning 0 so the probe discriminates. ⛔ Not edited:packages/client/src/index.ts:2151— JSDoc "404 [no_draft] when there is nothing to publish" (domain:cli).docs/qa/platform-checklist/areas/access-security.jsonat:1816,:1818,:1875(domain:devx).docs/qa/platform-checklist/areas/studio-authoring.jsonat:477and:514— the same probe finds two more checklist carriers in a second area file (domain:devx).docs/adr/**is Tier H, and triage forbade touching it on this card):docs/adr/0005-metadata-customization-overlay.md:348anddocs/adr/0010-metadata-protection-model.md:480and:485each show a wire payload whoseerroropens with the retired tag.codein lowercase ("code": "not_overridable"), which disagrees with the SCREAMING_SNAKE rule and with what the runtime actually emits — a pre-existing inaccuracy this change does not cause but does sit beside.packages/spec/src/api/protocol.test.tsand mock refusals inplugin-security,cli,service-packageandpackages/client/src/client.test.tsstill fabricate the bracketed spelling. They stay green because they construct their own errors, which is exactly how the idiom reaches the next author who copies one.the published seed bodies failed…once its tag was removed, and now opensThe published….⛔
packages/*/CHANGELOG.mdentries that quote the old tags are release-owned history in the past tense, describing what the code did when they were written. Not a falsified claim, and not touched.🤖 Generated with Claude Code
https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Generated by Claude Code