Repository navigation
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 |
… axis, not the retired [no_draft] opener `publishItem`'s docstring (packages/client/src/index.ts, ships into dist/index.d.ts) still documented the refusal as `404 [no_draft]`, the bracketed lowercase message opener PR #19683 (#16245) retired from every metadata-protocol producer on the 2026-08-29 maintainer ruling that `error` is human language and `code` is the machine token. The runtime has emitted `status: 404, code: 'NO_DRAFT'` with no bracketed opener since that PR landed; this corrects the docstring to match, following the same `404 `NO_DRAFT`` convention already used in packages/spec/src/api/plugin-rest-api.zod.ts. No behaviour change. Card relation carried in the pull request body only. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
… a bracketed restatement of its own code (objectstack-ai#19830) Fixes objectstack-ai#19709 Clause-②: no The runtime authoring gate's `422 INVALID_METADATA` refusal no longer opens its message with `[invalid_metadata]`, a bracketed restatement of the `code` the same throw declares. The 2026-08-29 maintainer ruling defines one envelope semantics: «`error` is HUMAN LANGUAGE, `code` is the MACHINE TOKEN, and a prefix is removed *because* the same fact already rides the `code` axis». This is limb ① of the card only. The `packages/rest` and `packages/runtime` limbs belong to another lane and are untouched here. 5 files, head `58b526dbd`. Precedent followed: `7a25a3ee9c` (PR objectstack-ai#19683). ## What changed - **`packages/metadata-protocol/src/runtime-authoring-gate.ts`**: the thrown message now opens with the sentence: `flow/leave_approval failed author-time validation: 1 issue — flows[0].nodes[1].config.approvers[0].value [approval-expression-invalid]`. `code`, `status`, `issues` and `rulesRun` are byte-identical. The `[rule]` locators inside the headline stay: they say which rule produced each finding, and no other field on the message carries that. - **`protocol.bracketed-refusal-opener-absence.test.ts`** (the existing pin): `PRODUCERS` widens to this file. There is no second pin. Two structural changes were needed to widen it, and each was measured before it was written: - **Per-file refusal floor.** The pin required at least 5 `new Error(` per producer file. This producer constructs exactly one, so a naive widening reds for being small rather than for being wrong. `PRODUCERS` is now a file-to-floor map (`protocol.ts` 5, `sys-metadata-repository.ts` 5, `runtime-authoring-gate.ts` 1). The whole-family floor of 30 is unchanged. - **The interpolated arm now captures its expression**, and one expression is declared by name: `advisory.rule`. Line 715 of the producer is the advisory log line's `[${advisory.rule}]` locator, a continuation literal after the `[Protocol]` prefix. It belongs to the `[rule]`-locator vocabulary the card says to leave alone. The line-based scan cannot tell a mid-line continuation from an opener, so the exemption is spelled out rather than inferred, and it is keyed on the exact expression, never on "anything interpolated". - A behavioural case is added for this producer: `evaluateRuntimeAuthoringGate` with the gate-local schedule-org refusal asserts `INVALID_METADATA`, 422, no opener, the opening sentence, and the `[rule]` locator. - **`protocol.runtime-authoring-gate.test.ts:214`**: flipped (A3 class i, below). - **`docs/qa/platform-checklist/areas/studio-authoring.json`**: two lines corrected (A2, below), revision 2 to 3. - **`.changeset/19709-authoring-gate-bracketed-opener.md`**: `patch`, `@objectstack/metadata-protocol`. ## Measured, not assumed ### A1: the opener census for this producer On `c11852406` the file has one `new Error(` and one bracketed opener: `:774`, `[invalid_metadata]`, beside `code = 'INVALID_METADATA'` / `status = 422`. That count comes from running the pin's own scanner over the file, not from a grep. The other `invalid_metadata` mention (`:40`) is doc prose about the envelope. The remaining bracketed literals are not this family: `:714` and `:759` are `[Protocol]` logger prefixes, and `:715` is the `[rule]` locator above. Reachability is not re-derived: `withoutDeclaredCodePrefix` strips only the `CODE:` spelling, as the precedent measured. ### A2: which producer the two checklist lines describe The PM's reading holds and triage's does not. Both lines describe `saveMetaItem`'s Zod spec-validation refusal in `protocol.ts` (`failed spec validation`). Neither describes this card's producer (`failed author-time validation`). The item saves with `?mode=draft`, and the author-time gate never judges a draft. - **`:477` clause: false today on two counts.** That producer lost its bracketed opener in `7a25a3ee9c`. On the REST and dispatcher doors its message is also a `path [zod code]` headline, with the per-issue prose carried on `issues[]`. Measured on `c11852406` with a temporary probe (removed, never committed), for a field missing its type: `object/qa_invalid_probe failed spec validation: 1 issue — fields.amount.type [invalid_value]`, `INVALID_METADATA`, 422. The word `Required` appears nowhere in it; Zod 4 reports `Invalid option: …`. The clause now quotes the headline, with the placeholders written the way the file already writes them. - **`:514` source pointer.** It cited `packages/runtime/src/http-dispatcher.test.ts#error` and quoted that test's hand-built mock message as the producer's shape. The mock stays untouched (class ii). The pointer now says what the test actually pins: the 422, `INVALID_METADATA`, and `details.issues` threading. `packages/metadata-protocol/src/protocol.ts#specValidationFindings` is added beside it. The gate's anchor sweep resolves the new anchor: 619/637 before, 620/638 after. ### A3: every test outside the producer that carries the removed bytes Scanned across the whole tree, not by following CI: `git grep` for `[invalid_metadata]`, for lowercase `invalid_metadata`, for `failed author-time`, and for opener-shaped message assertions. **(i) Asserts on this producer's real output** | file | disposition | | --- | --- | | `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts:214` | `rejects.toThrow(/invalid_metadata/)`, green only because the tag spelled the token. **Updated.** It now asserts `code` + `status` plus the opening sentence and the rule locator, so it cannot pass on an empty or generic message. | | `packages/objectql/src/publish-package-drafts-response-conformance.test.ts:590-615` | Reads this producer's real output through `publishPackageDrafts` and `dist`. It asserts only retained bytes (`failed author-time validation`, `[approval-expression-invalid]`, `: 1 issue — `) and keys on `f.code === 'INVALID_METADATA'`. **Not edited.** Re-run after rebuilding: 15/15. | **(ii) Builds its own string: a mock or fixture, left untouched** | file | what it is | | --- | --- | | `packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts:216` | a mock `saveMetaItem` imitating the spec-validation producer | | `packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts:220` | same | | `packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts:151` | same | | `packages/plugins/plugin-security/src/permission-set-projection.test.ts:163` | same | | `packages/rest/src/rest-4xx-message-truncation.test.ts:297,319` | constructs its own error and asserts the REST layer passes it through | | `packages/runtime/src/domains/meta-put-falsy-body.test.ts:67` | constructs its own error | | `packages/runtime/src/http-dispatcher.test.ts:255` | a mock `saveMetaItem` rejection | | `packages/spec/src/api/protocol.test.ts:1254,1478,1559` | response-schema parse fixtures; `:1478` imitates this producer's old text, and the schema accepts any string | | `packages/runtime/src/domains/packages-seed-apply-read-decorations.test.ts:406` | an absence assertion (`not.toContain('invalid_metadata')`), unaffected | ### A4: log sites that print only the message **Nothing to restore at the producer.** The producer's own two log lines never interpolate its `err.message`. One is the advisory warn, which is not about the refusal. The other is the migration-hatch warn, which composes its text from the issues and never carried the tag. The refusal does reach **generic consumer sites that print any error's `e.message` and nothing else**: | site | face | carries `code` elsewhere? | | --- | --- | --- | | `protocol.ts`, `publishPackageDrafts` rollback `console.warn` | operator log | the response row does (`failed[].code`); the log line does not | | `protocol.ts`, `duplicatePackage` copy-failure `console.warn` and `failed[].error` | log + response data | no: `DuplicatePackageResponseSchema.failed[]` has no `code` field | | `packages/runtime/src/domains/packages.ts`, visibility-flip `logger.error` (`Cause: e.message`) | log | no | | `plugin-security` permission-set projection, untyped-host `warn` fallback | log | the primary path hands the Error object to `logger.error` | After this PR those lines keep the fixed sentence `failed author-time validation` and the `[rule]` locators, but not the `INVALID_METADATA` token. All four have carried the precedent family's refusals without a token since `7a25a3ee9c`; this PR adds one more code to that set. None is in the producer. The bounded in-place exemption does not hold, on its "no other claim holds the file" condition: `protocol.ts` is reserved to a concurrent card this round, and `packages.ts` is limb ③'s lane. The precedent's remedy would be one line per site: print the declared code, `(code=…)`, at the log site. It is reported to the seat rather than done here. ⛔ The opener was not re-added. ## Reverse verifications Each was committed first, mutated through `scripts/ablation-replace.mjs` (the anchor must hit, and blob counts are checked on disk), then restored with proof: blob equals the HEAD blob and `git diff HEAD` is empty. No ablation needed a rebuild, because every test involved resolves the producer through a relative `src` import. 1. **Opener re-inserted** at `:782` (blob `c3698c313409` to `669df99b04ed`): **red, 3 failures**. The pin named `runtime-authoring-gate.ts:782`, its behavioural case failed, and the door test at `:214` failed. Restored to `c3698c313409`. 2. **Interpolated opener** `[${args.type}]` at the same anchor: **red, 2 failures**. The capture-group change kept the interpolated arm alive, and the exemption is narrow. Restored. 3. **`advisory.rule` exemption emptied** in the pin (blob `fc4a3d904d03` to `ac342c7f8dcc`): **red, 1 failure**, naming `runtime-authoring-gate.ts:715`. The exemption is load-bearing. Restored. ## Tests - `pnpm --filter @objectstack/metadata-protocol test`: **187 files / 2660 tests pass**; 3 files and 19 tests skipped (pre-existing). `typecheck` exit 0. `tsc --listFiles` holds 190 test files, including both edited ones. - `packages/objectql` `publish-package-drafts-response-conformance.test.ts`: 15/15, run after rebuilding this package. `dist/index.js` carries the new sentence (positive control: 1). The old literal reads 0 in both `dist/index.js` and `dist/index.cjs`. - Lint, narrowed and proven at `58b526dbd`. `eslint.config.mjs` enables no type-aware linting: no `parserOptions.project`, and its rules are per-file. The only file inputs it reads are two baseline JSONs this diff does not touch, so no untouched file's verdict can move. The three touched `.ts` files were linted with `--no-inline-config --format json`: 3 files, 0 errors, 0 warnings. The `.json` and `.md` files match no `files` glob. ## Gates `node scripts/pm/dispatch-gates.mjs --commands`, run on `58b526dbd`, derived 62 commands. All 62 ran, plus the 4 roster families it flagged as sharing a directory with these paths (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`). **All 66 exit 0.** The PM-named families are among them: `check:platform-checklist`, `check:dispatcher-error-vocabulary` and `check:engine-double-contract`. `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` first answered `PREREQUISITE NOT MET` (exit 3, explicitly not a pass). The whole-workspace build they ask for was paid (73/73 tasks), and all three then read 0. `--ran` reconciliation: **62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN**, a derived zero because every line carries its exit code. ## Changeset `patch` on `@objectstack/metadata-protocol` only, following the claim's `Clause-②: no`: every accept/reject verdict is byte-identical and no export moved. The package ships `dist`, and the message bytes are there, so the change does publish.⚠️ For the contract reviewer: the precedent declared `Clause-②: yes` / `minor` for the same shape of change. This PR follows the claim's reasoning, recorded on the card, and does not re-decide it. ## Acceptance notes - **`docs/adr/0005-metadata-customization-overlay.md:348`** shows an example payload with the retired opener on the spec-validation producer. It is an ADR (a governed surface) recording a decision at its date, so it was not touched. Carrier: none. - **`packages/metadata-protocol/src/protocol-publish-drafts-closure.test.ts:15`** quotes a dated measurement (2026-08-21) of the rollback log line, including the old opener. It is a historical record, left as is. The same goes for the narrative comment at `plugin-security/src/permission-set-projection.ts:361` and the `CHANGELOG.md` entries, which are release-owned. - **The class (ii) mocks** imitate the spec-validation producer's pre-`7a25a3ee9c` text. They are drift, not consumers, and are left as the dispatch instructed. Carrier: none. - **`runtime-authoring-gate.ts:40`** says "`invalid_metadata`-shaped envelope". That is a lowercase spelling of the code in doc prose, not an opener. Left. - **`origin/main` moved one commit since the base** (`c1dfa5241b`, a spec fix plus its changeset). It overlaps none of these paths and was not merged. CI builds the merge ref. --- _Generated by [Claude Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… not a retired message spelling (objectstack-ai#19843) Fixes objectstack-ai#19705 Clause-②: no ## What `docs/qa/platform-checklist/areas/access-security.json`, item `access-security.permission-matrix-edit-loop` (P1): clause 2 and its `verify` quoted the nothing-to-publish refusal by a bracketed lowercase opener inside the message text. That opener was retired from every metadata-protocol refusal in `7a25a3ee9c` (PR objectstack-ai#19683), so a checker following the clause verbatim against a live answer recorded a false failure. Per the card's fix shape (adopted by triage): the clause and verify now key on the **code axis**: HTTP 404 whose body carries `code: 'NO_DRAFT'`, and say explicitly to judge the refusal on status + `code`, never on message prose. Pass/fail semantics are unchanged. - `revision` 2 -> 3, with a new append-only `history` entry (revision 3, ref objectstack-ai#19705). The revision-2 history entry that quotes the old spelling is untouched (history is append-only). - New `source` row: `packages/metadata-protocol/src/sys-metadata-repository.ts#promoteDraft`, the producer. ## Runtime site (verified on `origin/main` 71ef221) - Producer: `SysMetadataRepository.promoteDraft` (`packages/metadata-protocol/src/sys-metadata-repository.ts`) throws message `No pending draft exists for TYPE/NAME — nothing to publish.` with `code = 'NO_DRAFT'`, `status = 404`. No bracketed opener. - Route: `POST /meta/:type/:name/publish` in `packages/rest/src/rest-server.ts` routes the throw through `handleRouteError`; the wire body shape `{ error, code: 'NO_DRAFT' }` is pinned in `rest-4xx-message-truncation.test.ts` and `rest-5xx-message-sanitization.test.ts`. - Status and code match the card. Absence of the opener family is pinned by `protocol.bracketed-refusal-opener-absence.test.ts`. ## Carrier re-derivation over `docs/qa/**` Instrument: `git grep -ni no_draft -- docs/qa` plus a family census `git grep -noE '[0-9]{3} \[[a-z][a-z_]+\]' -- docs/qa`, at 71ef221 (base). Radius: every tracked file under `docs/qa/`. Lit control: `git grep -nic invalid_metadata -- docs/qa` answers 3 hits in each of two area files, so the instrument reaches both files. - Before: `no_draft` 3 lines, all in `access-security.json` (clause, verify, revision-2 history). - After (db81a89): 1 line, the immutable revision-2 history entry. No other `docs/qa/**` carrier of the retired `no_draft` spelling exists. ## Gates (all at `db81a89b`) `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 13 commands; all 13 run, reconciled with `--ran` (13 derived, 13 run, 0 NOT-MEASURED, derived from recorded exit codes). All exit 0. `check:doc-formula-expressions` first exited 3 (PREREQUISITE NOT MET, formula/lint unbuilt, nothing measured); after `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` it re-ran exit 0. `check:platform-checklist` anchor counts: symbol anchors before 620/638 resolved against 310 cited sources, after 621/639 against 311 (the one new source row resolves); residual 18 unchanged; 17 file floors held; line citations 0 before and after. No floor or ledger touched. ## Changeset None. Docs/QA-only: `docs/qa/**` ships in no package's `files[]` (`git grep -l docs/qa -- '**/package.json'` = 0 hits; positive control: the `files` arrays exist and list `dist`). This is `skip-changeset` territory; per the dispatch, no label write was attempted from this seat. ## Acceptance notes - `docs/qa/platform-checklist/areas/studio-authoring.json` is off-limits for this card by the triage ruling. Its `[invalid_metadata]` lines have already been re-spelled on `main` (its item revision 3, dated 2026-09-23). It still carries `422 [writable_package_required]` in one clause and one source row, for the metadata-protocol write door, which no longer emits that opener (`SysMetadataRepository` throws `WRITABLE_PACKAGE_REQUIRED` / 422 with plain prose). Same false-failure class as this card; reported for the seat to file or attach to objectstack-ai#19709, not edited here. - `packages/runtime/src/domains/packages.ts` still opens two refusals with a bracketed restatement of their declared code (`[writable_package_required]` and `[duplicate_source_not_a_base]`). The absence pin covers only `metadata-protocol`. Reported, not edited here (outside `docs/qa/**`). - `packages/rest/src/rest-server.ts`: the comment above the publish route still reads `404 [no_draft] when nothing to publish`. This is a code comment, not a refusal, so it goes here as a note only. - `packages/client` JSDoc carrying the same spelling is objectstack-ai#19704 (a `domain:cli` card). objectstack-ai#19704 is not addressed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_ Co-authored-by: Claude <noreply@anthropic.com>
…aft] opener (objectstack-ai#20187) Fixes objectstack-ai#19704 Clause-②: no `publishItem`'s JSDoc in `packages/client/src/index.ts` (ships into `dist/index.d.ts`) documented the publish-refusal spelling as `404 [no_draft]` — the bracketed lowercase message opener the 2026-08-29 maintainer ruling retired from every `@objectstack/metadata-protocol` producer via objectstack-ai#16245 / PR objectstack-ai#19683 (`7a25a3ee9c`). The runtime has answered `status: 404, code: 'NO_DRAFT'` with no bracketed opener since that PR landed. This corrects the docstring to name `NO_DRAFT` on the `code` axis, following the same 404 `NO_DRAFT` convention already used in `packages/spec/src/api/plugin-rest-api.zod.ts`. No behaviour change. The card's own `Blocked-by: objectstack-ai#16245` is satisfied — that PR merged on 2026-09-22 (`7a25a3ee9c`). Triage originally planned to fold this card into objectstack-ai#19705's round; per the claim's serial-constraints note, that round closed via a PR touching only QA files and never picked this line up, so this card proceeded on its own. ## Measured - **Site confirmed**: `packages/client/src/index.ts:2152` read `404 [no_draft] when there is nothing to publish.` on `origin/main` `455dcc06`. - **Tree-wide sweep**: `git grep -n --fixed-strings "[no_draft]"` over the tracked tree (excluding `content/docs/releases/` and `CHANGELOG.md`s) found no other present-tense claim of this spelling inside `packages/client/src/`. Control words in the same file — `NO_DRAFT` and `[item_locked]` are zero there while `publish` hits 55 times — show the grep discriminates rather than being silently broken. - **Published-surface question (triage's UNMEASURED item)**: built `@objectstack/client` and grepped the emitted `dist/index.d.ts` — the docstring **does ship** (with positive controls, two neighbouring JSDoc lines, confirmed present too). This is a published-surface correction, so it carries `.changeset/19704-client-no-draft-jsdoc.md` (`patch`), not the `skip-changeset` route. - **Runtime producer**: `SysMetadataRepository.promoteDraft` (`packages/metadata-protocol/src/sys-metadata-repository.ts:946`) throws `status: 404`, `code: 'NO_DRAFT'`, message `"No pending draft exists for {type}/{name} — nothing to publish."` — no bracketed opener. The new docstring names exactly that. - No test pins the old `[no_draft]` text (zero `git grep` hits across `packages/client/`'s test files). ## Acceptance notes Two more present-tense claims of the same *retired bracketed-opener* shape turned up while measuring the above, both outside this card's file surface (packages/client/src/ + the [no_draft] spelling specifically) — reported rather than fixed here: - `packages/rest/src/rest-server.ts:8427` — a source comment (`404 [no_draft] when nothing to publish.`) on the same `POST /meta/:type/:name/publish` route this card's producer serves. Same defect family as objectstack-ai#19704 and objectstack-ai#16245, different file. - `packages/client/src/index.ts:6297` — a sibling docstring (`Get a sharing rule by id or name. 404 [RULE_NOT_FOUND] when absent.`) for a *different* code. Its producer (`packages/plugins/plugin-sharing/src/sharing-rule-service.ts:818`, bare `throw new Error('RULE_NOT_FOUND')`) and REST mapping (`packages/rest/src/rest-server.ts:12807-12808`, `:12920`) never emit a bracketed message either — the objectstack-ai#16245 shape recurring on a code that ruling never touched. Neither is the `[no_draft]` spelling this card claims, and the second isn't in scope by spelling; both are named in the round report for the dispatching seat to route. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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