Skip to content

feat(spec): Operation Message Catalog gains refusal-situation keys for approvals recall and sharing write-denial - #12626

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-12493-operation-message-refusal-keys
Aug 27, 2026
Merged

os-zhuang merged 2 commits into
mainfrom
claude/issue-12493-operation-message-refusal-keys

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #12493

Clause-②: yes; dispatched at review tier.

The spec-side half of the shape-A ruling on the approvals-recall card (triage 2026-08-26). Two measured hardcoded-English FORBIDDEN refusals gain user-situation keys in the Operation Message Catalog, in all four platform locales, so their emitters can later call renderOperationMessage instead of throwing raw English. Scope held: keys, catalog text, catalog-convention tests, changeset — zero edits outside packages/spec (plus the changeset file). Emitter call-site conversion stays with the consumer cards: #11993 remains open (its services half converts the approvals emitter after this lands) and #12260 remains open (probable second consumer, repair-pending; its consumer side is not implemented here).

New keys

  • record_write_denied — the user can see this record, but changing or deleting it is beyond their access. Measured emitter (recorded, not edited): packages/plugins/plugin-sharing/src/sharing-plugin.ts:1120, the by-id write gate throwing the FORBIDDEN insufficient-privileges string with the object API name and row id interpolated in; op there is update or delete, so the key covers both write verbs — the user's situation and remedy are the same, and the refused verb stays a developer fact on developerMessage/details. Deliberately NOT record_access_denied restated: that sentence ("You do not have access to this record") would be false on a row the read path already admitted — the user is typically looking at the record the write gate refuses.
  • approval_recall_not_submitter — the user asked to recall an approval request someone else submitted. Measured emitter (recorded, not edited): packages/plugins/plugin-approvals/src/approval-service.ts:2706. The copy names who CAN act — the submitter, or an administrator (the code path carries a privileged-admin override, so sending the user to an administrator is accurate).

Both sentences take no placeholders, re-derived per site per the family rule (the only nameable facts at both throw sites are object API names and opaque ids, which must not reach a toast). Both render through renderOperationMessage with the identical override/locale/fallback ladder as the record_access_denied family and are addressable for deployment overrides as errors.record_write_denied / errors.approval_recall_not_submitter. Module header updated: members list, situation table (two rows), and per-site placeholder derivations.

Tests

New describe block in packages/spec/src/system/operation-message.test.ts mirroring the existing family battery: caller-locale rendering (zh-CN + en pinned by value), base-language regional match (ja), en fallback for an uncarried locale, developer-vocabulary absence in every locale (family list plus FORBIDDEN and the insufficient-privileges phrase), distinctness from every sibling key per locale, placeholder-free catalog entries asserted on the entry (not the rendering), deployment-override wins, throwing-i18n-service containment. The pre-existing "every locale defines every key en defines" completeness test now also covers the new keys.

Verification (all through scripts/pm/os-verify-lock.sh; exit codes captured before any pipe; verdict lines quoted from the gates' own output)

Union re-run at the final head 8ee1941 (after merging origin/main forward — see landing-watch below):

  • pnpm --filter @objectstack/spec build — "check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present."
  • pnpm --filter @objectstack/spec test — "Test Files 435 passed (435) / Tests 11533 passed (11533)" at head 8ee1941.
  • pnpm --filter @objectstack/spec typecheck — clean, including "check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json" (the new test file is inside the checked test program).
  • pnpm --filter @objectstack/spec check:generated — "All 14 generated artifacts are up to date." Zero baseline row deltas: the catalog ships as constants, not schemas, so no generated artifact moves.
  • pnpm --filter @objectstack/spec check:authorable-surface — green; "authorable-defaults/ verified against upstream e2d1f80 — 1255 default(s) unchanged". The baseRev-trails note is the documented informational line, not an error.
  • Gate derivation: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at head (answer taken from this checkout of objectstack at 999bf8b, re-confirmed after merge). All derived path-matched and convention-triggered families ran locally and are green, including check:doc-formula-expressions (after building its prerequisite packages), check:dev-prereqs (after the full package-workspace build), check:engine-double-contract ("OK — 689 pinned, 134 in the DEBT ledger, 3 exempt"), check:where-matcher ("303 matcher(s) discovered, 303 answer... correctly or refuse it loudly"), check:query-options-erasure ("ratchet holds... none new"), check:cross-package-test-inputs, and check:type-check-debt ("--re-measure: OK — 31 ledger entr(ies) re-measured, 1687 raw tsc error(s) total, none above its recorded number"). Ratchet families re-run at the merged head 8ee1941. Left to CI: the whole-repo eslint run (pnpm lint) and the workflow families whose population is unreachable from these paths — CI runs the full farm on every PR regardless.
  • pnpm check:nul-bytes — green.

Reverse verification

Direction predicted, then observed: turn red. With the catalog file reverted to the base revision (mutation proved on disk: 0 grep hits for the new key in the catalog, 6 in the kept test file) and the new tests kept, the targeted suite went RED — 7 failed / 27 passed, all seven failures in the new block (the override-wins case stays green by design: its translate stub answers regardless of the catalog). Restore proved by hash: git checkout HEAD on the path, git diff HEAD empty, disk blob hash equal to the HEAD blob hash; re-run 34/34 green. No build step is part of this loop — the test imports the module by relative source path, so no dist is on the resolution path.

Changeset

.changeset/operation-message-refusal-keys.md, minor on @objectstack/spec, following the landed published-surface precedent (the FLOW_INPUT_SCHEMA_INVALID ledger-row changeset): states plainly that this release ships catalog keys only, the emitters still throw raw English until the consumer halves land, and what operators see change when they do.

Landing-watch

origin/main moved mid-flight by two CLI-test commits (e2d1f80, 1ca763b — disjoint from packages/spec); merged forward (merge, not rebase) and the union plus ratchet families re-ran at the merged head 8ee1941 as reported above. Also disjoint from the in-flight #12497 surface (security/permission.zod.ts + registries) — no overlap with this diff.

Generated by Claude Code


Generated by Claude Code

claude added 2 commits August 27, 2026 00:42
…cord_write_denied and approval_recall_not_submitter

The spec-side half of the ruled shape-A localization path: two measured
hardcoded-English FORBIDDEN refusals (the sharing middleware's by-id write
denial, plugin-approvals' non-submitter recall) get situation keys in all
four platform locales so their emitters can convert to
renderOperationMessage in the consumer cards' own lanes. Keys only — no
emitter edits here.

Both sentences take no placeholders, re-derived per site per the family rule.
record_write_denied is deliberately not record_access_denied restated: the
sharing gate fires on rows the read path already admitted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xGvxcwPRTJfA7RfjXEYA4
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0043c92240ffe5b53df43753f9ab1edd2446b474 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1d6e10f5190d6a507fa12c6da332fab22cac1736 — the merge of head 8ee19415799865a2d0acc259818196057ba7cc21 into base 0043c92240ffe5b53df43753f9ab1edd2446b474, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1d6e10f5190d6a507fa12c6da332fab22cac1736 && git checkout 1d6e10f5190d6a507fa12c6da332fab22cac1736
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0043c92240ffe5b53df43753f9ab1edd2446b474 8ee19415799865a2d0acc259818196057ba7cc21 && git checkout -B drift-repro 0043c92240ffe5b53df43753f9ab1edd2446b474 && git merge --no-ff 8ee19415799865a2d0acc259818196057ba7cc21

node scripts/docs-audit/affected-docs.mjs --json 0043c92240ffe5b53df43753f9ab1edd2446b474

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:system tests tooling labels Aug 27, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 27, 2026 01:56
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 27, 2026
Merged via the queue into main with commit aa5994e Aug 27, 2026
35 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-12493-operation-message-refusal-keys branch August 27, 2026 02:18
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…d decision in words instead of a tracker number (stage 27) (objectstack-ai#21997)

Part of objectstack-ai#20749
Clause-②: no

Stage 27 of this card: the next area of class (e), the test strings
shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513.
This stage takes the second and last name-ordered `system/` group: the
16 id-bearing test files under `packages/spec/src/system/` from
`metadata-form-zod-reconciliation.test.ts` to `worker.test.ts`. Those
files carried 63 messages and 70 tracker ids. All 70 now either state
what their record decided, in words (form D), or are dropped where the
string already says it. No needle sits in this group. Text only: no
assertion, identifier, test count or code comment changes, and no file
is renamed. This finishes `system/` for this class.

## Census at the base (`aa09db58c9`)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs`
(md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5
`dda605c54745b4a60cc14c9a686e4eff`), byte-identical to the copies stages
10 to 26 used. A literal counts as a test title when its folded message
is argument 0 of a `describe` / `it` / `test` call, `.each` / `.skip` /
`.only` chains included. Everything else is an "other" string.

The worktree was cut from `origin/main` at `aa09db58c9`, the claim's
base and stage 26's landing. Both instruments read **191 messages / 200
ids in 53 files**, the seat's reading and stage 26's head reading.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| `system/` (this PR: all 16) | 16 | 63 / 70 | 45 / 49 | 18 / 21 |
| `ui/` | 5 | 7 / 7 | 0 | 7 / 7 |
| `ai/` | 1 | 2 / 2 | 0 | 2 / 2 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **53** | **191 / 200** | **162 / 168** | **29 / 32** |

The group reads **63 messages / 70 ids in 16 files**, the seat's figures
file for file:

| file (under `system/`) | messages / ids | titles | other |
|:--|--:|--:|--:|
| `metadata-form-zod-reconciliation.test.ts` | 21 / 24 | 4 / 4 | 17 / 20
|
| `metadata-persistence.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `metrics.test.ts` | 4 / 5 | 4 / 5 | 0 |
| `notification-event-migration-retirement.test.ts` | 3 / 3 | 2 / 2 | 1
/ 1 |
| `notification.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `object-storage.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `operation-message.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `registry-config.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `settings-manifest.test.ts` | 5 / 5 | 5 / 5 | 0 |
| `stack-server.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `tenant-provisioning-family-retired.test.ts` | 1 / 2 | 1 / 2 | 0 |
| `tenant.test.ts` | 3 / 5 | 3 / 5 | 0 |
| `tracing.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `translation.test.ts` | 12 / 12 | 12 / 12 | 0 |
| `validation-message.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `worker.test.ts` | 1 / 1 | 1 / 1 | 0 |
| **16 files** | **63 / 70** | **45 / 49** | **18 / 21** |

Five more test files sit in the same name range and carry no id
(`migration`, `search-engine`, `security-context`, `supplier-security`,
`translation-typegen`). The 18 "other" strings are declared to the
text-only tool: the 17 ledger reasons in
`metadata-form-zod-reconciliation.test.ts` (`LEDGER` rows `:256`,
`:263`, `:270`, `:283`, `:426`, `:433`, `:440`, `:447`, `:454`, `:470`,
`:477`, `:484`, `:491`, `:507`, `:514`, `:521`, and the
`TOP_LEVEL_DEFERRED.view` reason at `:880`), and one expect failure
message at `notification-event-migration-retirement.test.ts:165`.

- **Controls.** Lit: `ai/build-progress.test.ts` (2 / 2) and three files
directly in `src/` (`assembled-package-body` 4 / 4,
`compose-key-dispositions-export.pin` 5 / 5,
`compose-stacks-action-collision-shape` 3 / 3), outside the group, read
the same at the base and at the head. Dark: the 16 files read 0 at the
head while 141 of their lines still carry `#` plus digits, every one of
them a comment line. Planted in a scratch tree: an id put into the
rewritten "page component copy, keyed by component id" title reads 1 / 1
(`title:describe`), the base text put back into the `:440` ledger reason
reads 1 / 1 (`other`), and an id put into a `metrics.test.ts` comment
reads 0.
- **A wider pattern** (any `#` plus digits) reads the same as the gate
pattern in all 16 files at the base, and 0 in all 16 at the head.
- **At the head:** 128 messages / 130 ids in 37 files. The 16 files read
0 / 0, `system/` is absent, and no other file moved.

## How the area was chosen

`system/` is taken in name-ordered file groups near the ~100-id bound,
the rule stages 20 to 26 used. Stage 26 named this group at 70 ids, and
this census reads 70, so no re-cut was needed.

**Named for the next stage** (cut from the head census, 128 / 130):
- **The files directly in `src/`:** 30 files, 118 messages / 120 ids
(117 / 119 titles, 1 / 1 other, at
`stack-cross-reference-envelope.test.ts`). The largest file is
`stack-inline-action-crossref.test.ts` at 13, and no file needs
splitting. It fits one PR and one text-only proof at 120, 20% over the
~100 bound. If the seat holds to "within about 10%", the name-order cut
is two groups: `assembled-package-body.test.ts` through
`inline-grid-column-carriers.test.ts` (18 files, 61 ids) and
`root-entry-migrations-split.pin.test.ts` through `stack.test.ts` (12
files, 59 ids).
- The needles: one stage, with an at-tier review. The four colour
literals stay, as stage 21 decided.

## What each id became

- **22 literals (24 ids)** now state a decision in words.
- **1 literal (1 id)** gets its subject back in words.
- **40 literals (45 ids)** drop a number the string already explains.

Every cited record was fetched with all its comments through REST. 37
records are cited, plus one decision-batch number (below): 35 answer
200, and 2 answer 404. The two that answer 404 were read from what
landed, through the commits endpoint (this checkout is shallow):
- **objectstack-ai#10926**, from `d173125fb8` (objectstack-ai#11438): the component-translation
`submitLabel` copy key is retired, option A per the maintainer ruling of
2026-08-22;
- **objectstack-ai#12493**, from `aa5994e17a` (objectstack-ai#12626, found through its
`@objectstack/spec` CHANGELOG entry): the catalog gains two situation
keys, `record_write_denied` and `approval_recall_not_submitter`, each
its own sentence, "deliberately not `record_access_denied` restated".

**The ledger reasons in `metadata-form-zod-reconciliation.test.ts`** (17
"other" strings). Before any was rewritten, every reader of the ledger
and of the file was found:
- **The file's own assertions** read the `why` text:
`e.why.startsWith(reason)` (the class phrase that leads each ruled root
row), `toContain('5861442317')` (the ruling record id, in every ruled
root row), ``toContain(`the \`${editor.type}\` type`)`` and
`toContain(editor.surface)` (the editor names), the unspellable union
arms in backticks, and `why.length > 20`. Every rewrite keeps all of
them, so no assertion moves: only the card numbers go, and the record id
`5861442317` stays.
- **Outside the file:** `packages/spec/scripts/lib/zod-graph.ts`,
`zod-graph.test.ts`, `scripts/pm/check-widening-tells.mjs`,
`metadata-form-declared-rows.pin.test.ts`, three `*.form.ts` comments
and the CHANGELOGs name the file. None reads a `why` string: they cite
the file in prose or comments. No gate, generated artifact, docs table
or self-test reads the text. So no non-test file moves.
- **objectstack-ai#19332's ruling is record `5861442317`** (batch objectstack-ai#229 item 3, A on
all five groups). Each reason already states its class and why, so
"(ruling record 5861442317, objectstack-ai#19332)" becomes "(ruling record
5861442317)" in 15 rows.
- **`:433`** cited objectstack-ai#19330 too: "(ruling record 5861442317; per-arm view
forms, ruled: one registered form per view kind)". objectstack-ai#19330's ruling A
(`5754204415`): "`view` is reconciled PER ARM. Each view kind … gets its
own registered metadata form".
- **`:454`** cited "objectstack-ai#18164 batch objectstack-ai#209 item 1 A" after the record id
`5755653853`. objectstack-ai#18164's ruling `5755653853` is decision batch objectstack-ai#209 item
1, letter A, so `objectstack-ai#209` is a batch number, not a citation (objectstack-ai#209 itself is
an unrelated 2026 docs PR). The reason already says what was decided
("the picker placed there"; "its offer was decided as that picker in the
object designer's select-field editor"), so it now reads "(ruling record
5861442317; the picker placed there by ruling record 5755653853)".
- **`:880`** (`TOP_LEVEL_DEFERRED.view`) already says "It is reconciled
per arm, each arm against its own registered form", so "(the objectstack-ai#19330
ruling, letter A)" becomes ", as ruled".

**"ruled" appears in two rewritten strings**, both on objectstack-ai#19330's ruling A
(`5754204415`): `:433` and `:880` above.

**The same-id titles stage 26 listed:**
- the five `(objectstack-ai#15679)` titles (`metrics:500`, `object-storage:864`,
`registry-config:215`, `tracing:524`, `worker:561`) now say "carry their
unit in the key name" / "carries its unit in the key name". objectstack-ai#15679 is
objectstack-ai#14478's ruling B (`5548763981`) for `system/`: the fifteen duration
keys carry their unit in the key name. That is stage 25's objectstack-ai#15677 reading
and stage 26's form;
- `translation.test.ts`'s six: `objectstack-ai#16772` and `objectstack-ai#6080` state their
decisions (below); `objectstack-ai#10926`, `objectstack-ai#21257`, `objectstack-ai#11287` and `objectstack-ai#4667` drop,
because each title already says it (retired, retired, the keys the
resolver acts on, retired).

**Stated in words** (22 literals):

| record | literal (under `system/`) | now reads | the decision |
|:--|:--|:--|:--|
| objectstack-ai#19332, objectstack-ai#19330 | `metadata-form-zod-reconciliation.test.ts:433` |
"(ruling record 5861442317; per-arm view forms, ruled: one registered
form per view kind)" | Above. |
| objectstack-ai#3786 | `metadata-form-zod-reconciliation.test.ts:886` | "metadata
form ↔ Zod reconciliation — a hand-written form held to its schema by a
gate" | The template for the hand-copied-list pattern: derive from the
one source, and where it cannot be derived, a coverage assertion is the
gate. |
| objectstack-ai#15679 (objectstack-ai#14478 ruling B) | `metrics.test.ts:500`,
`object-storage.test.ts:864`, `registry-config.test.ts:215`,
`tracing.test.ts:524`, `worker.test.ts:561` | "… carry their unit in the
key name" / "… carries its unit in the key name" | Above. |
| objectstack-ai#15939, objectstack-ai#14478 | `metrics.test.ts:584` | "metrics JSDoc-only durations
carry their unit in the describe and the key name" | objectstack-ai#15939's ruling
(`5564447683`, option 2): a key whose JSDoc names a unit its describe
does not is a divergence, refused; the unit moves into the describe,
where objectstack-ai#14478's rule puts it in the key name. Ruling A (`5635659224`)
remediates per file. |
| objectstack-ai#17785 | `tracing.test.ts:565` | "the OTel exporter and performance
durations carry their unit in the describe and the key name" | The
tracing file's remediation under objectstack-ai#15939's ruling A: the four keys
renamed with their unit, and the unit published in the describe. |
| objectstack-ai#16194 | `notification-event-migration-retirement.test.ts:165` (expect
message) | "… — its runner was retired, with no operator door and no
boot-time invoker" | The ruling (`5582372148`, batch objectstack-ai#88): retire; no
operator door and no platform invoker. |
| objectstack-ai#7414 | `operation-message.test.ts:101` | "operation message catalog —
permission_denied, the 403 refusal as localized user copy" | The 403
refusal renders through the operation message catalog in the caller's
locale, naming no object, operation or position. |
| objectstack-ai#7451 | `operation-message.test.ts:193` | "operation message catalog —
the row-level user copy, a sentence per situation" | The row-level
denials get their own keys (`record_access_denied`,
`record_change_not_allowed`), not the grant denial restated. |
| objectstack-ai#12493 (404) | `operation-message.test.ts:306` | "… sharing write
denial and approvals recall, two keys of their own" | What landed in
`aa5994e17a`, above. |
| objectstack-ai#5933 | `settings-manifest.test.ts:230` | "Specifier.valueDomain — a
declared standard domain is the boundary, `options` a UI list" | When
`valueDomain` is declared, the standard domain is the enforcement
boundary and `options` degrades to a UI convenience list. |
| objectstack-ai#5131 | `settings-manifest.test.ts:246` | "is optional — an undeclared
specifier keeps exhaustive-options semantics, enforced at save" | A
`select` value outside its `options` is refused at save. objectstack-ai#5933 keeps
that for an undeclared specifier. |
| objectstack-ai#7327 | `settings-manifest.test.ts:306` | "`visible` — the settings
visibility grammar, narrowed to what the save-time evaluator runs" |
Direction (b): narrow the declared type to the grammar the save-time
evaluator implements, not CEL. |
| objectstack-ai#15811 | `settings-manifest.test.ts:338` | "REFUSES an `ast`-only
envelope — an evaluated slot requires a non-blank `source`" | Ruling A
(`5644350409`, batch objectstack-ai#122 item 2): every engine-evaluated expression
slot requires a non-blank `source`. |
| objectstack-ai#4938 | `stack-server.test.ts:61` | "server: carries only keys with a
consumer — the retired HttpServerConfig keys stay out" | The maintainer
ruling A (`5173147201`): the unreachable `HttpServerConfigSchema` and
its seven dead keys are retired. |
| objectstack-ai#16772 | `translation.test.ts:840` | "dashboard global-filter copy,
addressable from a bundle by filter name" | Finding B:
`dashboards.NAME.globalFilters.KEY` becomes a bundle group, keyed by the
filter name. |
| objectstack-ai#6080 | `translation.test.ts:901` | "page component copy, keyed by
component id" | Page component copy gets a bundle address by component
id. |
| objectstack-ai#7646 | `translation.test.ts:1082` | "screen-flow copy — a `flows`
bundle group, runner chrome kept out" | The recorded ruling
(`5253154523`): a `flows` surface for flow, screen and field copy;
runner chrome stays in the console's own catalog. |
| objectstack-ai#3957 | `validation-message.test.ts:90` | "renderValidationMessage —
English output is unchanged, the field label in place of the API name" |
Messages name the field by its label, through the message catalog. The
describe pins the English output as before, label for API name. |

**Subject back in words** (1 literal): `translation.test.ts:713` "should
reject the retired shape …" now names it, "the retired object-first `o.`
shape". objectstack-ai#3778 retired that shape for the `translation` type.

**Dropped where already stated** (40 literals, 45 ids). A number goes
only where the string already says its decision. Examples: the 15 ledger
reasons above; `:880`'s "(the objectstack-ai#19330 ruling, letter A)"; the tails
`(objectstack-ai#5280)`, `(objectstack-ai#14327)`, `(objectstack-ai#19332)`, `(objectstack-ai#18124)` x2, `(objectstack-ai#5933)`, `(objectstack-ai#4001)`
x3, `(objectstack-ai#14478, objectstack-ai#14519)`, `(objectstack-ai#14519)`, `(objectstack-ai#15939, objectstack-ai#14478)` on "→
schemaCacheTtlSeconds", `(objectstack-ai#10926)`, `(objectstack-ai#21257)`, `(objectstack-ai#11287)`, `(objectstack-ai#15178)`,
`(objectstack-ai#19620)`, `(objectstack-ai#4667)`; and the prefixes `objectstack-ai#18118` x2 (before "the retired
CEL arm"), `[objectstack-ai#16194]` x2, `[objectstack-ai#4616]` and `[objectstack-ai#4739 / objectstack-ai#16325]`. The one 404
number among them (objectstack-ai#10926) goes only where the title already states what
landed.

**No file is renamed.**

## Readers

- **Needles:** none. The 17 ledger reasons are data the file's own
assertions read for their class phrase, record id and editor names, all
kept (above); none reads an id. The one declared expect message is a
failure message (the second argument of `expect`), not an expected
value. `notification-event-migration-retirement.test.ts` reads the
`NOTIFICATION_EVENT_MIGRATION_ID` docblock for a claim matrix's absence
and for `RETIRED` / `os migrate` / `boot-time invoker` /
`files-to-references`, none of them an id. No title or message in the
group is matched against a source docblock or another file's text.
- **Test-name filters:** none. No tracked script, workflow or package
config passes `-t` / `--testNamePattern` to vitest; the one vitest `-t`
hit is a README example under `packages/qa/dogfood` filtering its own
fixture.
- **Snapshots:** none. No `__snapshots__` directory is tracked under
`packages/spec`, and none of the 16 files calls a snapshot matcher.
- **Projects:** all 16 files run in `local`; none is in
`packages/spec/vitest.repo-tests.json`. The base-versus-head run below
takes both projects anyway.
- **By substring:** every old literal, its id-bearing fragment and a
window around each id (193 needles) was searched with `git grep` at the
base, across the tracked tree outside its own file. No gate, doc,
filter, snapshot, QA checklist entry or `scripts/check-*.mjs` self-test
reads one. The 26 hits are 16 published CHANGELOG lines, which quote
"form ↔ Zod reconciliation (objectstack-ai#3786)" and "strict from birth (objectstack-ai#4001)" as
release text, and 10 sibling hits among the five `(objectstack-ai#15679)` titles of
this group, all rewritten here.
- **One code comment quotes a title:** `tracing.test.ts:560` says "a
describe headed `Span.duration carries its unit`". The new title keeps
that prefix.

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each changed string leaf must sit in a test-call title
position or on a declared line, must carry a tracker id before, and must
carry no `#` plus digits after. This stage declares the 18 lines named
above.

- **Result:** 16 of 16 files SAME on all three legs, with the per-file
counts predicted in writing before any edit (2026-10-06T13:25:38Z).
- **Totals:** 63 changed string leaves in 63 literals: 45 titles and 18
declared. The diff's `+` and `-` lines are exactly the 63 planned lines
as multisets, and every file keeps its line count.
- **Controls (14 of 14 as predicted on the first run, on scratch copies,
each anchor hit once):** identifier rename DIFF; numeric literal DIFF;
comment edit COMMENT DIFF; a non-title string given an id VIOLATION; a
rewritten title given a new id VIOLATION; a title that was id-free at
base edited VIOLATION; one title reverted to base SAME; an `it.each` row
given an id VIOLATION; an undeclared expect message changed VIOLATION; a
title re-split into a `+` chain DIFF; a declared ledger reason reverted
to base SAME; the declared deferral reason given a new id VIOLATION; the
declared expect message given a new id VIOLATION; a template-literal
title given a new id VIOLATION.
- **Templates and tables:** no `.each` title, no `$name` / `%s`
placeholder and no table row changes.

**Test counts:** the 16 files were run at the base, before the edit, and
at the head, in the same worktree, with `--project local --project
repo`. Both sides read 747 tests in 16 files, all passed, with the same
count and status sequence per file in 16 of 16. 302 full test names
change, and each changed name equals the base name with the planned
replacements applied: 0 mismatches. One full name repeats on each side,
the same one: an `it.each` pair under "translation unknown-key
strictness" that already printed alike at the base. No head name carries
`#` plus digits (302 base names did). No source escape sits in a planned
anchor, so the comparison tool met none.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
16 touched files are in it, and no `*.test.ts` at all (`files[]` ships
`src/**/*.zod.ts`, not tests). The controls
`src/system/translation.zod.ts`, `src/system/metrics.zod.ts` and
`dist/index.mjs` are in it.
- In the built `dist/`, two new phrases and an old one each read in 0
files. The control `Unrecognized key` reads in 42.

So this PR publishes nothing, and no changeset is added.

## Verification (at `320cf687c7`)

- `pnpm turbo run build` over all packages: 71 / 71, through the shared
verify lock (turbo exit 0, recorded to a file).
- `@objectstack/spec`:
  - `vitest run --project local`: 619 files, 18485 passed, 1 todo.
- `typecheck`: exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 16 group
files, counted by path with `tsc --listFilesOnly -p tsconfig.test.json`.
- `check:generated`: all 15 generated artifacts up to date, against the
`dist/` the build above wrote.
- **Gates:** `dispatch-gates --commands` derived 79 families, the same
79 as stage 26. All 79 exit 0. `--ran` reconciles: 79 derived, 79 run, 0
NOT-MEASURED, 0 UNRUN, every family with its exit code recorded. The
five roster families marked as sharing a directory with this diff
(`check:meta-url-spelling`, `check:spec-changes`,
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`) each exit 0.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 16 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 16 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 126 changed lines (+63 /
-63).
- A control-byte scan over the 16 changed files finds none.

## `main` since the base

Re-fetched just before this PR opened, `origin/main` was one commit past
the base (`6befe19c6e`, objectstack-ai#21990). It touches 4 files in
`packages/metadata-protocol` and `.changeset/`, none of the 16 and none
under `packages/spec/src/system/`, so `main` was not merged. `git
merge-tree` onto `6befe19c6e` is clean, and none of the 4 open PRs
touches any of the 16 files.

## Acceptance notes

- **Same-id test titles in other packages** stay: 37 lines in 14
packages (`plugin-security` 9, `lint` 5, `cli` 4, `objectql` 4,
`service-settings` 4, `core` 2, `spec/scripts` 2, and one each in
`metadata-protocol`, `metadata`, `plugin-approvals`, `qa/dogfood`,
`sdui-parser`, `service-automation` and `service-i18n`), each package's
share under the objectstack-ai#20513 lane children. Examples:
`plugin-security/src/permission-denied-user-copy.test.ts`'s four `objectstack-ai#7414
—` describes and
`metadata/src/migrations/notification-event-migration-retirement.test.ts:54`'s
`[objectstack-ai#16194]` twin. No same-id title is left in this card's own census.
- **Kept record ids in the ledger reasons:** `5861442317` (held by the
file's own `toContain('5861442317')` assertion) and `5755653853` are
GitHub comment ids of ruling records, outside the gate's
three-to-five-digit pattern, not tracker numbers.
- **Code comments with live ids** remain in these files, among them the
`fieldGroups` / `inlineColumns` / root-coordinate ledger banners in
`metadata-form-zod-reconciliation.test.ts` (six comment lines still say
"5861442317, objectstack-ai#19332"), the `// objectstack-ai#7646 —` banner in `translation.test.ts`
and the `objectstack-ai#17785, ruling A on objectstack-ai#15939` block in `tracing.test.ts`. Code
comments are not this card's share.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants