feat(spec): refuse a duration key whose JSDoc names a unit its describe does not - #17635
Conversation
…be does not
`check:duration-unit-keys` read a key's unit from `.describe()` and
`.meta({ description })` only. A duration-shaped `z.number()` whose unit was
stated solely in the JSDoc block above it was a census row with `[prose: -]`
and no verdict — and the blindness did not merely miss such keys, it produced
confident wrong prose about why they were missed.
Ruled 2026-09-07 (decision batch #65): JSDoc is developer commentary, not
governed prose, so the gate does NOT start reading it as a unit channel. What
it refuses is the DIVERGENCE — JSDoc names a unit, describe names none (or
there is no describe at all) — as `unit-in-jsdoc-not-in-describe`. The JSDoc is
read in one direction only: to refuse, never to satisfy. A key with no unit in
either channel stays listed and not judged, unchanged.
The self-test pins the three measured sites as positive controls, a
unit-in-both-channels key as the negative control, and the two ways the reader
could over-fire: a `//` line comment is not a JSDoc block, and an enclosing
declaration's JSDoc is not inherited by the first property inside it.
Also repairs the two remaining imprecise recorded reasons this blindness
produced, both comment-only: the retired-key entry for
`SandboxConfig:process.timeout` and the burn-rate `window` pin comment in
`metrics.test.ts` both said "outside the gate's population" where the truth is
"inside the census, outside the verdict", and both now name the unpublished
JSDoc unit. `registry.ts` regenerated to mirror the entry.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 136 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 c9fb9105ca4dad8363bc6739d3b8f67b076f7ea8 && git checkout c9fb9105ca4dad8363bc6739d3b8f67b076f7ea8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e248c4d688ba46fc86af199cce7660b55b8cfb30 605b7f60a4d0ce228a6e61aff748c9de91b8dfea && git checkout -B drift-repro e248c4d688ba46fc86af199cce7660b55b8cfb30 && git merge --no-ff 605b7f60a4d0ce228a6e61aff748c9de91b8dfea
node scripts/docs-audit/affected-docs.mjs --json e248c4d688ba46fc86af199cce7660b55b8cfb30 |
Status update — the blocker CHANGED on 2026-09-11T14:08Z and this PR's body never said so
What the body still says, and why it is staleThe body's closing line reads "Its substantive blocker is unchanged and is a maintainer question — see the decision card on #15939." That is no longer true. The decision was made: the director seat recorded Ruling A on #15939 at 2026-09-11T14:08Z (batch #115), carrying the maintainer's own 「同意」. ⇒ this PR is not waiting on a maintainer. It is waiting on work, and that work is now filed. The red is still by construction — that part is unchanged
What actually blocks it nowRuling A ordered the remediation first, per file, with this gate landing last into a tree it already reads as clean — which is the recommendation this PR's own body made. That is now executed:
18 renames + 3 describe-only = the full delta this PR enumerated. #15939 is now
⇒ This PR's disposition, stated plainlyParked, draft, and correctly so — but the reason to write on it from now on is "waiting on #17780–#17786", ⛔ not "waiting on a ruling". When all seven are on
Generated by Claude Code |
…ration-unit-keys-jsdoc
…ration-unit-keys-jsdoc
…ration-unit-keys-jsdoc # Conflicts: # packages/spec/src/system/metrics.test.ts
…ted tree The note's replacement prose was written while the neighbouring `RuntimeConfig.resourceLimits.timeout` rename was still pending, and asserted that key was "inside the gate's census and outside its verdict", that its unit was "not missing, only unpublished", and that it was "a rename waiting on that gate change". #15939 ruling A's per-file remediation has since landed that rename, so all three read false on this tree. The note now repairs the original wrong reason ("outside the gate's population") without re-asserting a landed rename as pending, and points at the neighbour's own entry instead of restating its story. registry.ts regenerated to mirror it. Claude-Session: https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt Co-authored-by: Claude <noreply@anthropic.com>
…iated tree The changeset was written while all 21 offenders were pending. It claimed a "0 -> 21" population delta on this tree and "two wrongly-recorded reasons repaired". Both now read false: ruling A's seven per-file cards have landed the whole population, so the gate reads zero offenders here, and the metrics.test.ts half of the prose repair was carried by #17783 when it renamed that key. Numbers re-measured on this tree: 0 offenders among 211 duration-shaped numeric keys across 2482 source files. Claude-Session: https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt Co-authored-by: Claude <noreply@anthropic.com>
Contract review — PR #17635, in-seat clause-② review at CONTRACT_REVIEW_TIERHead-sha: Adopted verbatim from a context-isolated, adversarial review subagent. Its brief carried the card, the recorded rulings and the PR itself — ⛔ not the dispatch order and ⛔ not this seat's own conclusions — and was written to invite rejection. The verdict below is reproduced whole and unedited; ⛔ this seat did not rewrite, abridge or polish any part of it, and the two out-of-scope gaps it found are carried through rather than dropped. Served-tier verification (降档保险丝). Head reviewed: ① Derived judgments Accept-set / public-surface changes the diff produces:
② Semver grading
③ Boundary-flag disposition
PASS — Out of scope (real, must be filed):
epic PM for #15939 · Generated by Claude Code |
… its ADR-0087 entry (objectstack-ai#17761) Part of objectstack-ai#17493 Items ① and ② of the card. ⛔ Item ③ is a **ruling, not a refactor** — it is measured and reported here, and deliberately **not implemented**; the card stays open on it, and this PR therefore carries no closing keyword. Nothing in this diff changes behaviour. The refusal already shipped (objectstack-ai#17322 at `registerFlow`, objectstack-ai#17495 at `objectstack validate`); what shipped with it was the notification, and this is that half. - **Clause-②: no** — this PR puts no new key on any published payload. --- ## ① The on-site ruling that had become false `packages/spec/src/automation/flow-node-expression-paths.ts` — the docblock of `structuralConditionRefusal`. It admitted a whitespace-only string on this ground, verbatim as it stood: > every **string**, including a whitespace-only one. What a non-empty string *says* stays `validateExpression('predicate', …)`'s verdict, and **a whitespace-only condition meaning `false` is consistent on both sides and is ruled correct, not a defect.** objectstack-ai#15807 removed that ground (`FlowEdgeSchema.condition` composes `EvaluatedExpressionInputSchema`), objectstack-ai#17322 ruled on the disagreement that left, and objectstack-ai#17495 finished it at the third door. ⛔ Not deleted — it records a real decision, so the edit says **what changed it**: - The admission itself is **unchanged and still correct**. This function answers the SHAPE question; a string is a well-shaped structural condition. The blank is refused **beside** it, by the imported evaluated-slot rule, answering `EVALUATED_EXPRESSION_SOURCE_REQUIRED` and not `STRUCTURAL_CONDITION_SHAPE_REFUSAL`. - A new section records **why item ③'s question is open**, without answering it. - The same stale ground sat three lines from the symbol, in `flow-node-expression-paths.test.ts`'s own case comment ("not authored on both sides and stays so"). Same defect class, same file face, mechanical: the comment is corrected, both assertions untouched. ⭐ Declared as a bounded on-site fix beyond the dispatched file face. ## ② The ADR-0087 entry named only the edge slot `packages/spec/src/migrations/entries/semantic/18.flow-edge-condition-evaluated-slot-source-required.ts` — `surface` and `acceptanceCriteria`. Both named only `edges[].condition`, so a consumer replaying the chain was told to sweep the edge key alone and a deployment carrying a blank `config.condition` on a node was **never told to look**. Both now name both structural slots, plus the node key's own locator phrase (the one the structural pass builds, e.g. `node 'gate' (start) condition`) beside the edge's `flows.N.edges.N.condition`, and the sweep carries the warning that deleting a `condition` from a `start` node **opens the trigger gate** rather than preserving it. - `id`, `replacement` and `reason` are untouched, per the card. - No backticks in `surface` — measured, 0 (`build-upgrade-guide.ts` renders it inside a code span). - **No new entry.** This is one decision reaching its second slot, which is what both follow-up changesets (`blank-node-condition-refused-at-registration.md`, `validate-refuses-blank-structural-condition.md`) already declared by taking the `not-required (already-registered …)` disposition against this very entry. - `packages/spec/src/migrations/registry.ts` is **regenerated by the repo's own generator**, `pnpm --filter @objectstack/spec gen:migration-registry` — ⛔ never hand-edited. `check:migration-registry` is green. ## ⛔ ③ Measured, reported, NOT implemented `registerFlow` still **ACCEPTS** a whitespace-only string at both sibling ledger `predicate` slots, on this head, against controls that prove the probe reaches them: | slot | valid CEL (positive control) | envelope with a blank source (refusal control) | `' '` | `''` | |:--|:--|:--|:--|:--| | `config.conditions[].expression` | ACCEPTED | REFUSED — `PREDICATE_SLOT_STRING_REFUSAL` at `config.conditions[0].expression` | **ACCEPTED** | **ACCEPTED** | | `screen.fields[].visibleWhen` | ACCEPTED | REFUSED — `PREDICATE_SLOT_STRING_REFUSAL` at `config.fields[0].visibleWhen` | **ACCEPTED** | **ACCEPTED** | | `config.condition` (lit control — the slot objectstack-ai#17322 DID narrow) | ACCEPTED | REFUSED | **REFUSED** | **REFUSED** | `evaluateCondition(' ')` and `evaluateCondition('')` both answer `false` — the same silent dead branch. The current behaviour is pinned as correct by objectstack-ai#15572 at `packages/services/service-automation/src/decision-predicate-envelope.test.ts` (the `it` block at `:110`–`:114` on this head; the card cited `:113`–`:117` from `aefbb07b2`, a 3-line drift, the pin itself intact). ⛔ Narrowing those slots re-judges that pin and moves a published accept-set: **a ruling, not a refactor**. `packages/services/**` is untouched by this diff — 0 files. ## 验收备注 - `packages/lint`'s `predicateSlotRefusal`-side prose and `flow-node-expression-paths.test.ts:189` also rest on "consistent on both sides" — but for the **ledger predicate slots** that statement is still **true** (resolver skips the blank, evaluator answers `false`, and neither has been rebound). Noted, not filed: nothing to correct until item ③ is ruled on. - `packages/spec/src/migrations/registry.ts` is a hot shared file: PR objectstack-ai#17638 and PR objectstack-ai#17635 both regenerate it from different entry files. No conflict at this head; `origin/main` merged at `5a77c75b29` and the registry regenerated and re-verified after. ## Verification Every run below is on the final head `5a77c75b29` (`origin/main` merged at that commit), with the gate's own verdict line, never a bare exit status read through a pipe. - `pnpm --filter @objectstack/spec build && check:generated && typecheck && test` — VERDICT `command-exit 0`; **473 test files / 13436 tests passed**. - The one test-layer file this diff touches, re-run alone: `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/automation/flow-node-expression-paths.test.ts` — 1 file / **26 tests passed**. - `node scripts/pm/dispatch-gates.mjs --commands` derived **84** gate families for this change set; **84 run, 82 green, 0 unrun** — reconciled with `--ran`, exit codes captured before any pipe. The two non-green exited **3 = PREREQUISITE NOT MET, i.e. NOT MEASURED and not a finding**: `check:dual-build-cjs-loads` and `check:type-check-debt` both refuse without a repo-wide build closure, and both are declared to CI, which builds one. - Item ②'s own gate, `check:adr-0087-registration` — exit 0, verdict: "this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)". `check:migration-registry` exit 0, verdict: "src/migrations/registry.ts is current (202 semantic, 167 retired-key, 178 retired-def)". `check:spec-changes` and `check:upgrade-guide` both report their artifact up to date; `check:docs` reports 222 generated files in sync. - `eslint . --no-inline-config --format json` over the **whole repo**, eslint's own population read from its JSON output: **6638 files, 0 errors, 0 warnings**. No narrowing claimed and none needed. - `check:nul-bytes` exit 0 (8441 text files), plus a control-character self-scan over every changed file: 0 hits. Changeset: `.changeset/17493-node-door-refusal-residues.md`, `@objectstack/spec: patch`, card-scoped filename in the tree's prevailing spelling. --- _Generated by [Claude Code](https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…e() prose (objectstack-ai#17953) Fixes objectstack-ai#17786 Clause-②: no Ruling A on objectstack-ai#15939 (director seat, 2026-09-11T14:08Z, maintainer 「同意」, decision batch objectstack-ai#115) split PR objectstack-ai#17635's 21-row delta into seven per-file remediation cards and sequenced the gate itself to land last. This is the one of the seven that needs no rename: > …plus **one small card for the three describe-only sites** (`durationMs` ×2, `estimatedDurationMs`) ## What changed Three `.describe()` strings — the governed prose channel batch objectstack-ai#65 named: *"The governed prose channel is `.describe()` / `.meta({ description })` — the text that reaches the reference pages and the published dist"*. Each of these keys already carries `Ms` in its name and already says milliseconds in the JSDoc above it; only the channel an author (very often a model, ADR-0033) actually reads was missing it. | site | key | `.describe()` before | `.describe()` after | |:--|:--|:--|:--| | `packages/spec/src/kernel/plugin-loading.zod.ts:104` | `durationMs` | none at all | `Duration in milliseconds` | | `packages/spec/src/system/app-install.zod.ts:146` | `durationMs` | `Installation duration` | `Installation duration in milliseconds` | | `packages/spec/src/system/deploy-bundle.zod.ts:129` | `estimatedDurationMs` | `Estimated execution time` | `Estimated execution time in milliseconds` | Plus the generated mirror of exactly those three rows under `content/docs/references/**`, and one `patch` changeset. ⛔ **Not a rename.** No key is added, removed or renamed, so there is no ADR-0087 conversion, no tombstone, and `packages/spec/src/migrations/registry.ts` is untouched — this PR does not contend with the open PRs that move that generated file. ## Premise verification, re-measured on this tree The card's table was measured on `origin/main` @ `fce7cd4c46`; this branch is cut from `bd25e897d`. All three sites were re-located **by content**, never by the card's line numbers. | premise | reading on this tree | |:--|:--| | three sites exist, one per file | found by content; the line numbers happen to be unmoved (104 / 146 / 129) | | lit control `z.number` per file | 6 / 2 / 7 — identical to the filing seat's reading | | dark control (fabricated key name) | 0 / 0 / 0 | | `plugin-loading` `durationMs` has no describe at all | confirmed — the whole file carried 2 `.describe(` calls, neither on this key | | the other two have a describe without a unit | confirmed verbatim | | every key name already carries its unit | confirmed — `durationMs`, `durationMs`, `estimatedDurationMs` | | the diff does not move `migrations/registry.ts` | confirmed — 7 changed paths, none of them that file | | `Clause-②: no` still holds on the actual diff | confirmed — no key, no export, no accept/reject outcome moves; `check:api-surface` and `check:authorable-surface` both green | | `system/MigrationPlan:estimatedDurationMs` stays put in the authorable ratchet | confirmed — `authorable-surface.base.json` and `authorable-surface/system.json` are byte-unchanged and still carry the entry (dark control 0) | ## The measurement the card asked for: is a changeset owed? The card and its triage comment both refused to let this be assumed. Measured after `pnpm --filter @objectstack/spec build`, over the paths `packages/spec` `files[]` actually publishes: | probe | `dist/` | `json-schema/` | verdict | |:--|--:|--:|:--| | **subject** `Duration in milliseconds` | 24 | 6 | ships | | **subject** `Installation duration in milliseconds` | 8 | 2 | ships | | **subject** `Estimated execution time in milliseconds` | 8 | 2 | ships | | **positive control** describe `Objects created/updated` | 4 | 2 | ships, as expected | | **negative control** `no exemption by blindness` (lives only under `packages/spec/scripts/`, a path outside `files[]`) | 0 | 0 | does not ship, as expected | | **dark control** a fabricated needle | 0 | 0 | 0 everywhere, so the zeros above are readings | ⇒ **A changeset is owed**, `patch`, `@objectstack/spec`. There is a second published consumer beyond `dist`: the generated reference pages, all three of which render the changed string. ⭐ **One measured refinement worth recording, because it cuts against the obvious reading of "reaches the published dist".** `dist/` alone does **not** discriminate the two prose channels. JSDoc text — and even a bare `//` line comment — rides into the emitted bundles verbatim: the JSDoc-only sentence `Objects created or updated` reads **4** in `dist/` and **0** in `json-schema/`, and the `//` comment `Plugin loaded at runtime` reads **4** in `dist/` and **0** in `json-schema/`. So `dist` presence is necessary and not sufficient evidence that a string reached the governed channel; `json-schema/` is the surface that decides it. The ruling is unaffected — describe prose is the only one of the two that reaches the reference pages and the JSON schemas — but a future round measuring "does it ship" with a `dist` grep alone would get a true answer for a false reason. ## Local verification Heavy runs routed through `scripts/pm/os-verify-lock.sh`; verdicts read from the lock's own `VERDICT command-exit` line, never a bare status. - ① dependency closure `pnpm --filter '@objectstack/spec^...' build` — `VERDICT command-exit 0`, **empty closure** (`No projects matched the filters`): `packages/spec` has no workspace dependencies, so step ① is a documented no-op here. - `pnpm --filter @objectstack/spec build` — `VERDICT command-exit 0` (203s, shared box), run before every dist-reading gate. - `pnpm --filter @objectstack/spec typecheck` — `VERDICT command-exit 0` (`tsc --noEmit` + `check:scripts-typecheck` + `check:test-typecheck`). - `pnpm --filter @objectstack/spec test` — `VERDICT command-exit 0`, **475 test files / 13504 tests passed**. - Targeted re-read of the three sites' own suites — `deploy-bundle.test.ts` (which pins `estimatedDurationMs: 150`), `app-install.test.ts`, `plugin-loading.test.ts` — `VERDICT command-exit 0`, 3 files / 40 tests passed. - `pnpm --filter @objectstack/spec check:generated` proved exactly **1 of 15** artifacts stale — `content/docs/references/**` — and it was regenerated with the repo's own generator (`pnpm --filter @objectstack/spec gen:docs`), ⛔ never hand-edited. Re-run after regeneration: all 15 up to date. - Gate families derived from the **actual** change set with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, every command run with its exit code landed to disk, then reconciled with `--ran`. Results are in the report comment on objectstack-ai#17786. - Consumer sweep, measured rather than predicted: no tracked file outside `packages/spec` and `content/docs/references` carries any of the three describe strings, and no test file anywhere names `plugin-loading.mdx`, `app-install.mdx` or `deploy-bundle.mdx` (lit control: 145 `.mdx` mentions do exist in test files, so that zero is a reading). The two cross-package tests that read `content/docs/references/**` at all read `automation/time-relative-trigger.mdx` and `api/analytics.mdx`. ## Deviation from the dispatched file surface — declared, not silent The dispatch declared the file surface as the three `.zod.ts` files plus `.changeset/`. This PR also carries **three regenerated `content/docs/references/**` pages**. That is not scope creep: `check:docs` is a required gate, AGENTS.md requires the spec artifacts be regenerated before pushing, and `check:generated` named this artifact and only this artifact as stale. The three regenerated rows are the same three rows this change edits, byte for byte. Nothing else in `content/docs/` moved. ## Acceptance notes - ⛔ `packages/spec/scripts/check-duration-unit-keys.ts` and its self-test are **untouched** — that gate belongs to PR objectstack-ai#17635, which Ruling A sequences to land last. Its red on that branch is designed and is not addressed here. - ⛔ `content/docs/releases/` untouched. - `noted, not filed:` `packages/spec/src/kernel/plugin-loading.zod.ts` states every one of its member descriptions in JSDoc and gives only the enclosing object a `.describe()`, so the generated reference table for `PluginLoadingEvent` renders empty description cells for `type`, `pluginId`, `timestamp`, `metadata` and `error`. Only `durationMs` is in this card's population, so only it was given prose. Successor: whoever widens the describe-coverage question past duration keys — no PR or person is queued on that file today, so this is an observation with no carrier, deliberately not filed as a card. - `noted, not filed:` the JSDoc channel reaching `dist/` verbatim (measured above) means the phrase "JSDoc is developer commentary and is not prose in the sense of the objectstack-ai#14478 rule" is a statement about the **governed** channel, not about what is physically published. Nothing in the ruling depends on the difference, and no gate reads it. Successor: none. ⛔ This PR is a draft and stays one. An in-seat clause-② contract review is owed before it may turn ready or enqueue — the clause-② enqueue gate's path leg fires on `packages/spec/src/**` regardless of the `no` declaration. That review is the PM's step. Authored in session `session_015c5G6TmpMKgnusmTpD7Ntt` (epic PM lane for the objectstack-ai#15939 subtree; the claim comment on objectstack-ai#17786 is `5651943470`). --- _Generated by [Claude Code](https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…objectstack-ai#17784) (objectstack-ai#17954) Fixes objectstack-ai#17784 Clause-②: yes Executes director-seat **ruling A on objectstack-ai#15939** (2026-09-11, maintainer 「同意」, decision batch objectstack-ai#115) — the per-file remediation of the objectstack-ai#14478 duration-unit rule. This card owns exactly one row, in `packages/spec/src/system/tenant.zod.ts`. The gate PR (objectstack-ai#17635) is sequenced to land LAST and is untouched here.⚠️ The declaration reads `yes`, not the `no` ruling A wrote: the PM corrected it on the card (comment `5652103109`) under the mechanical floor in `references/contract-review.md` — a new key on a published payload is always `yes` — and a rename is a removal plus a new key. `needs:contract-review` is hung on card and PR; the changeset is graded `minor` accordingly, matching the four sibling duration-rename changesets of this same rule (objectstack-ai#15677 · objectstack-ai#15678 · objectstack-ai#15679 · objectstack-ai#15680), every one of which shipped as `feat(spec)!` under `## 17.4.0`. ## What changed `SchemaLevelIsolationStrategy.performance.schemaCacheTTL` named its unit in a source JSDoc — "Schema cache TTL in seconds" — and nowhere else. The `.describe()` that `content/docs/references/system/tenant.mdx` renders said "Schema cache TTL" and named no unit at all, so the reader of the published reference page could not tell 3600 seconds from 3600 milliseconds. | | before | after | |:--|:--|:--| | authored key | `performance.schemaCacheTTL: 3600` | `performance.schemaCacheTtlSeconds: 3600` | | published describe | `Schema cache TTL` | `Schema cache TTL in seconds` | | value + default | seconds, `3600` | unchanged | The new spelling is `Ttl`, not `TTL`, derived from how the suffixed family already spells itself on this tree rather than from the dispatch: `cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1, and no key-position `TTLSeconds` variant anywhere. The kit, following the objectstack-ai#15678 / objectstack-ai#15679 shape the ruling names: - a `retiredKey()` tombstone on the old spelling — `tsc` types it `never` and a value reaching the parse raises the rename prescription instead of being silently stripped (the nested `performance` object is not `.strict()`) - ADR-0087 **D3** semantic entry `tenant-schema-cache-ttl-unit-in-key` and the `RETIRED_KEYS_BY_MAJOR[18]` row `system/SchemaLevelIsolationStrategy:performance.schemaCacheTTL`, both as `migrations/entries/` files with `registry.ts` regenerated by `gen:migration-registry` (never hand-merged) - **no D2 conversion**: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is not a stored metadata row, so the chain has no seam that runs on it — the same reading `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this same file - pin tests on `SchemaLevelIsolationStrategySchema`: the refusal carries the rename prescription, the suffixed key parses at the magnitude the retired one carried with the same 3600 default, and the describe publishes the unit - `content/docs/references/system/tenant.mdx` regenerated by `gen:docs` — all four rows move and the tombstone prescription renders in place of the old describe - a `minor` changeset carrying the FROM to TO mapping and the ADR-0087 disposition ## The measurement this card was dispatched to produce This is the first of six renames, so the mechanical clause-② reading matters for the other five. **`node scripts/pm/check-widening-tells.mjs --declaration no` over this PR's own diff — exit 4, exactly one tell:** ``` ✗ T1 packages/spec/src/system/tenant.zod.ts:454 — a new key on a Zod object schema — the accept set gains a spelling an author may now write + schemaCacheTTL: retiredKey( ``` The tell is **not** on the rename. It is on the **tombstone**. Two synthetic probes separate the two halves on the same instrument: | probe | exit | tells | |:--|--:|:--| | the rename alone (one key line removed, one added, one change block) | 0 | none — the objectstack-ai#16943 REPLACEMENT budget absorbs it | | an added `retiredKey(` key line with no paired removal | 4 | T1 on the tombstone line | So the accounting is exact: the removed `schemaCacheTTL:` line buys one T1 unit, the added `schemaCacheTtlSeconds:` line spends it, and the added `schemaCacheTTL: retiredKey(` line is the surplus. Every `retiredKey()` retirement raises this, and the line it raises on is the one that makes the accept set strictly **narrower** — the key becomes `never`. That is the same inversion this file's own header records for `objectstack-ai#17300` (T2 on the retirement ledger's rows) and `objectstack-ai#17618` (T1 on a narrowed in-shape key), one door further along. Filed as **objectstack-ai#17955**; the diff was **not** reshaped to silence it and no gate was weakened.⚠️ Read the two readings apart, because they agree on the verdict for different reasons: the mechanical tell is a **false positive on the tombstone line**, while the `yes` this PR now declares rests on the contract-review floor (the rename really does add a key an author may write). `check-clause2-carriers --pair 17954` read exit 4 / C5 against the original `no` and reads **exit 0** against the corrected `yes`. ## Verification Heavy runs through `scripts/pm/os-verify-lock.sh`, verdicts read from its own `VERDICT` line; every gate's exit code captured before any pipe. - `pnpm --filter '@objectstack/spec^...' build` — exit 0, **empty closure** (`No projects matched`): `packages/spec` has no workspace dependencies, so step ① is a documented no-op - `pnpm --filter @objectstack/spec build` — `VERDICT command-exit 0`, run before every dist-reading gate and re-run after the last source edit - `pnpm --filter @objectstack/spec test` — **475 test files / 13507 tests passed** - `pnpm --filter @objectstack/spec typecheck` — exit 0 (`tsc --noEmit` + `check:scripts-typecheck` + `check:test-typecheck`) - `pnpm --filter @objectstack/spec check:generated` — **all 15 generated artifacts up to date** after `gen:docs`; `check:authorable-surface`, `check:api-surface`, `check:migration-registry`, `check:spec-changes` and `check:upgrade-guide` among them - `check:authorable-surface` does not move for this key and that is correct: the ratchet records top-level keys per def and this one is nested under `performance` (0 hits for the key across `authorable-surface/` and `authorable-surface.base.json`, against 4 for the `system/MigrationPlan:` lit control) - `check:duration-unit-keys` (the gate as it stands on `main`, not objectstack-ai#17635's) — exit 0 - `check:objectui-pin-citations` — exit 0, 16 asserting pin citations match `.objectui-sha` - cross-repo, per the ruling's execution note and AGENTS.md post-task step 4: `git grep` of the **pinned** objectui checkout at `.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694` (re-read from this tree) — `schemaCacheTTL` **0** occurrences across 6409 tracked files, beside lit controls `TTL` 112, `Ttl` 11, `tenant` 819, `cacheTTL` 1 and a dark control at 0, so the zero is a reading Gate set derived at the actual change set with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`; full per-gate exit codes, including the ones that could not run in this container, are in the round report on objectstack-ai#17784. ## Acceptance notes - noted, not filed: the two in-file sibling renames landed under objectstack-ai#14478 / objectstack-ai#14519 registered a D3 semantic entry but **no** `RETIRED_KEYS_BY_MAJOR` row, while objectstack-ai#15679's nested duration renames on `system/metrics.zod.ts` and `system/cache.zod.ts` registered both. This card follows objectstack-ai#15679 (the shape ruling A names) and registers both. Successor: whoever lands the remaining four rename cards in this epic, who meets the same choice. - ⛔ Not touched, deliberately: `packages/spec/scripts/check-duration-unit-keys.ts` and its self-test (PR objectstack-ai#17635, sequenced last, red by construction), the five sibling files owned by objectstack-ai#17780 objectstack-ai#17781 objectstack-ai#17782 objectstack-ai#17783 objectstack-ai#17785, and `content/docs/releases/`. ⛔ Draft on purpose: the in-seat clause-② review this PR now declares is owed before it may turn ready or enqueue. That is the PM's step, not this round's. Round report, with every reading above and its exit code: the `os-dev-report` comment on objectstack-ai#17784. --- _Generated by [Claude Code](https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…in the key name (objectstack-ai#17781) (objectstack-ai#17983) Fixes objectstack-ai#17781 Clause-②: yes Executes director-seat **ruling A on objectstack-ai#15939** (2026-09-11, maintainer 「同意」, decision batch objectstack-ai#115) — the per-file remediation of the objectstack-ai#14478 duration-unit rule. This card owns exactly one row, in `packages/spec/src/kernel/plugin-security-advanced.zod.ts`. The gate PR (objectstack-ai#17635) is sequenced to land LAST and is untouched here.⚠️ The declaration reads `yes`, not the `no` ruling A wrote, and the changeset is `minor`, not `patch`: both were corrected by the epic PM before dispatch and recorded publicly on objectstack-ai#17784 (`5652094166`) and objectstack-ai#15939 (`5652120294`) — a rename puts a spelling on a published payload no author could write before, which `references/contract-review.md`'s mechanical floor reads as a mandatory affirmative, and the objectstack-ai#15678 / objectstack-ai#15679 shape ruling A names shipped as `feat(spec)!` at **minor** in every one of its four landed siblings. `needs:contract-review` is hung on the card and on this PR; `check-changeset-no-major` reads that label as a clause-② affirmative carrier that overrides the body line, so the body line above reads the affirmative too. ## What changed `RuntimeConfig.resourceLimits.timeout` is the key the whole of objectstack-ai#15939 was filed about, and the one **objectstack-ai#15678 deliberately left alone**. It named its unit in a source JSDoc — "Execution timeout in milliseconds" — and nowhere else. The `.describe()` that `content/docs/references/kernel/plugin-security-advanced.mdx` renders read "Maximum execution time" and named no unit at all, so the reader of the published reference page could not tell 60000 milliseconds from 60000 seconds. `check:duration-unit-keys` reads `.describe()` and `.meta({ description })`, not JSDoc, so it listed the key in its census without judging it — neither an offender nor an exemption. That gap is objectstack-ai#15939, and this is its remediation. | | before | after | |:--|:--|:--| | authored key | `resourceLimits.timeout: 60000` | `resourceLimits.timeoutMs: 60000` | | published describe | `Maximum execution time` | `Maximum execution time in milliseconds` | | value + bound | milliseconds, `int().min(0)` | unchanged | The spelling is `Ms`, derived from how the suffixed family already spells itself on this tree rather than from the dispatch order: 29 key-position `timeoutMs` declarations across `packages/spec/src/**/*.zod.ts`, 40 distinct `*Ms` keys, and zero `timeoutMillis` / `timeout_ms` / `timeoutMS` variants anywhere in `packages/spec/src`. `SandboxConfig.process.timeoutMs`, renamed by objectstack-ai#15678 on this same file, already carries the token. The kit, following the objectstack-ai#15678 / objectstack-ai#15679 shape the ruling names: - a `retiredKey()` tombstone on the old spelling — `tsc` types it `never` and a value reaching the parse raises the rename prescription instead of being silently stripped (the nested `resourceLimits` object is not `.strict()`) - ADR-0087 **D3** semantic entry `kernel-runtime-config-timeout-unit-in-key` and the `RETIRED_KEYS_BY_MAJOR[18]` row `kernel/RuntimeConfig:resourceLimits.timeout`, both as `migrations/entries/` files with `registry.ts` regenerated by `gen:migration-registry` (never hand-merged) - **no D2 conversion**: a `RuntimeConfig` is the engine block of the `SandboxConfig` a host or a plugin security manifest constructs, `stack.zod.ts` declares no sandbox, security-policy or runtime-config collection, and it is not a stored `sys_metadata` row — so the chain has no seam that runs on it. That is the reading objectstack-ai#15678 recorded for the four keys it renamed. - `content/docs/references/kernel/plugin-security-advanced.mdx` regenerated by `gen:docs` — three rows move and the tombstone prescription renders in place of the old describe - a `minor` changeset carrying the FROM to TO mapping and the `adr-0087: registered` disposition One implementation detail worth a reviewer's eye: the tombstone string const is declared **above** `RuntimeConfigSchema`, not with the other four RETIRED consts below it. `gen:schema` and `check:authorable-surface` both run with `OS_EAGER_SCHEMAS=1`, which makes `lazySchema` evaluate its factory at module load — a const declared after the schema would be read from its temporal dead zone. The four existing consts are each already declared before the block that reads them; this one had to move to keep that true. ## The pin test this card was warned about — replaced, not silenced `plugin-security-advanced.test.ts` carried a NEGATIVE control asserting this exact key stays bare, with a comment stating why it exists: *"Without this test, a later sweep reads the four renames above as 'every timeout on this file'."* **This card is that later sweep**, so the guard succeeded by failing. | | before | after | |:--|:--|:--| | what it pinned | `RuntimeConfigSchema.parse({ resourceLimits: { timeout: 60000 } })` succeeds and returns `60000` | the bare spelling is REFUSED with the rename prescription; `timeoutMs` parses at the same magnitude beside its siblings; the describe publishes the unit; the two same-named `timeout` retirements on this file name their own shapes apart | | its comment | "leaves it bare — its describe names no unit … the JSDoc-channel gap is objectstack-ai#15939" | records what the slot used to pin, why the control was written, and that objectstack-ai#15939 IS the sweep it was written for | | where it lives | inside `describe('Plugin security durations carry their unit (objectstack-ai#15678)')` | its own top-level describe, so objectstack-ai#15678's block header — "FOUR durations … all four old spellings are `retiredKey()` tombstones" — stays exactly true about objectstack-ai#15678 | ⛔ It was not deleted, not weakened, not `.skip`ped, and not "fixed green" by loosening an assertion. The refusal assertions pin the ADR-0112-shaped facts a tombstone owes — the issue is not `unrecognized_keys`, and the message carries the FROM to TO mapping — never a bare `toThrow()`. ⛔ **objectstack-ai#15678's semantic entry was NOT amended.** Its *"One key deliberately left alone: RuntimeConfig.resourceLimits.timeout … it is outside this rename"* is a scoped, past-tense statement about what objectstack-ai#15678 did, it stays true, and it is published through the upgrade guide. Instead, **this card's own** semantic entry opens by stating that it completes what objectstack-ai#15678 deliberately left alone, citing objectstack-ai#15678 and objectstack-ai#15939, so the two read as a sequence rather than a contradiction. ## Verification Heavy runs through `scripts/pm/os-verify-lock.sh` (slot `issue-17781`), verdicts read from its own `VERDICT command-exit` line; every gate's exit code captured before any pipe (`cmd > log 2>&1; ex=$?`). - `pnpm --filter '@objectstack/spec^...' build` — **empty closure** (`No projects matched`): `packages/spec` has no workspace dependencies, so step ① is a documented no-op - `pnpm --filter @objectstack/spec build` — `VERDICT command-exit 0`, re-run after the last source edit and after the `main` merge - `pnpm --filter @objectstack/spec test` and `pnpm --filter @objectstack/spec typecheck` — see the round report for the counts - `pnpm --filter @objectstack/spec check:generated` — **all 15 generated artifacts up to date** after `gen:migration-registry` + `gen:docs`, re-run clean after the `main` merge. `check:authorable-surface` is green **without** regeneration and that is correct: the ratchet records top-level keys per def, and `kernel/RuntimeConfig:` carries exactly `engine`, `engineConfig` and `resourceLimits` — this key is nested one level below, so no row moves. - `check:duration-unit-keys` (the gate as it stands on `main`, not objectstack-ai#17635's) — exit 0 - `main` merged with `bash scripts/pm/os-regen-merge.sh`, never by hand; the registry was regenerated, and the sibling entries were asserted present by exact-name grep afterwards with a dark control at 0 ### Reverse verification of the new pin The refusal assertions were proven able to fail, from the committed state: the tombstone line was deleted, the deletion proven on disk (anchor `grep -o | wc -l` 1 → 0, blob hash `160ab186` → `50abe71c`), and the suite re-run — **2 tests red**, exactly the refusal pair, with the acceptance and describe tests still green because the ablation removed only the tombstone. Restored with `git checkout HEAD -- PATH` under an `EXIT INT TERM` trap and proven byte-identical (`git hash-object` back to `160ab186`, `git diff HEAD` empty, `git status --porcelain` empty); the suite re-run green. No ablation artefact is left in the tree. There is no `dist` leg: the test imports the schema by relative path from `src`, so the built package is not on the resolution path for this ablation. ### The cross-repo reading, with a lit control `git grep` of the **pinned** objectui checkout at `.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694` (re-read from this tree; the commit is reachable in the local clone, 6409 tracked files): | term | occurrences | |:--|--:| | `resourceLimits.timeout` | **0** | | `resourceLimits` | 2 | | `RuntimeConfig` | 236 (lit control) | | `timeout` | 832 (lit control) | | `sandbox` | 106 (lit control) | | a fabricated token | 0 (dark control) | Both `resourceLimits` hits are prose in `packages/app-shell` recording that objectui's own `AppShellRuntimeConfig` "share not one key" with the spec's `RuntimeConfig`, and both name only the top-level keys — they stay accurate after a rename one level below. ⇒ nothing in the pinned sibling authors this key, no objectui-side fix and no pin bump are owed (AGENTS.md Post-Task Checklist step 4). ## Acceptance notes - noted, not filed: `RuntimeConfig.resourceLimits.maxMemory` on this same nested object names its unit only in the JSDoc above it ("Maximum memory in bytes") while its describe reads "Maximum memory allocation" — the same channel gap this card closes for `timeout`. It is a **byte count, not a duration**, so it is outside objectstack-ai#14478's population and outside every card in the objectstack-ai#15939 epic; `check:duration-unit-keys` neither judges nor lists it. Recorded rather than filed because no ruling covers a unit-in-name rule for non-duration quantities and inventing one is scope. Successor: whoever proposes widening objectstack-ai#14478 beyond durations. - ⛔ Not touched, deliberately: `packages/spec/scripts/check-duration-unit-keys.ts` and its self-test (PR objectstack-ai#17635, sequenced last, red by construction — designed, not a defect), objectstack-ai#15678's semantic entry, the five sibling files owned by objectstack-ai#17780 objectstack-ai#17782 objectstack-ai#17783 objectstack-ai#17784 objectstack-ai#17785, and `content/docs/releases/`. ⛔ Draft on purpose: the in-seat clause-② contract review at `CONTRACT_REVIEW_TIER` that this PR declares is owed before it may turn ready or enqueue. That is the PM's step, not this round's. Round report, with every reading above and its exit code: the `os-dev-report` comment on objectstack-ai#17781. Authored in Claude Code session `session_015c5G6TmpMKgnusmTpD7Ntt` (`mode:cloud`), dispatched by the epic PM for objectstack-ai#15939. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…tion keys carry their unit in the key name (objectstack-ai#17986) Fixes objectstack-ai#17780 Clause-②: yes Executes director-seat **ruling A on objectstack-ai#15939** (2026-09-11, maintainer 「同意」, decision batch objectstack-ai#115) for `packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts` — 3 rows of the 21-row delta, following the objectstack-ai#15678 / objectstack-ai#15679 shape. The declaration above reads the affirmative: the rename puts three spellings on a published payload no author could write before. `check-clause2-carriers --pair 17986` reads **exit 0** — declaration readable in the fixed spelling, both carriers agree. ## The three rows, re-located by symbol Each key was confirmed to be the **only** key-position declaration of that name in the file (`interval` 1, `timeout` 1, `debounceDelay` 1; lit control `z.number` 12, dark control 0), and each JSDoc was read rather than inferred from the default: | key | JSDoc, as read | published `.describe()`, before | new spelling | |:--|:--|:--|:--| | `PluginHealthCheck.interval` | "Health check interval in **milliseconds**" | "How often to perform health checks (default: 30s)" | `intervalMs` | | `PluginHealthCheck.timeout` | "Timeout for health check in **milliseconds**" | "Maximum time to wait for health check response" | `timeoutMs` | | `HotReloadConfig.debounceDelay` | "Debounce delay before reloading (**milliseconds**)" | "Wait time after change detection before reload" | `debounceDelayMs` | All three are milliseconds. The `check:duration-unit-keys` census reads all three `[name: -] [prose: -]` on `origin/main` — no unit in the name and none the gate recognises in the published prose. `interval` is the sharpest: its one unit-shaped token was the parenthetical `(default: 30s)`, naming SECONDS for a value the schema bounds at `min(1000)` and defaults to `30000` milliseconds. ## Why these spellings Counted on this tree, not assumed from the card: 100 key-position `*Ms` declarations across `packages/spec/src`, of which `timeoutMs` is 29 and `intervalMs` is 3 — both renames land on names the surface already uses. The analogous question to the sibling round's `Ttl`-versus-`TTL` was asked for `debounceDelay` and answered the other way: it is the only debounce-shaped key spelling in the repo (5 key-position occurrences, all this key and its fixtures; no `debounceMs` variant anywhere), while the Delay-plus-`Ms` pairing is already attested (`maxDelayMs`, `initialDelayMs`, `retryDelayMs`, `delayMs`). There was no competing family spelling to choose between, so it takes the plain suffix. ## The kit - `retiredKey()` tombstone on each old spelling. Neither `PluginHealthCheckSchema` nor `HotReloadConfigSchema` is `.strict()`, so a bare deletion would be a silent strip — and here the stripped value lands on a `setInterval` period, a race deadline and a `setTimeout` delay. - ADR-0087 D3 semantic entry `kernel-health-check-and-hot-reload-durations-unit-in-key` **and** three `RETIRED_KEYS_BY_MAJOR[18]` rows — both, per the PM's settlement on objectstack-ai#15939. No D2 conversion: neither def is an authorable surface (both are library parameters a host passes to `PluginHealthMonitor` / `HotReloadManager` in TypeScript), the same disposition `plugin-auto-restart-never-reinitialised` and `hot-reload-watch-placeholder-retired` recorded for keys on these two defs. `registry.ts` is **generated** — `gen:migration-registry`, never hand-merged. - `@objectstack/core` moves with the rename (it is the only in-repo reader): both classes read the suffixed keys, and each registration-time refusal table gains a row so a host still passing an old spelling gets an ADR-0112 `VALIDATION_ERROR` / 400 naming the rename instead of `undefined` where a duration belongs. Those two runtime strings carry **no tracker id** — a runtime string reaches operators who cannot resolve one; the anchor sits in an adjacent `//` comment (`check:doc-authoring`). - Generated artifacts regenerated, never hand-edited: `authorable-surface/kernel.json`, `authorable-defaults/kernel.json`, `content/docs/references/kernel/plugin-lifecycle-advanced.mdx`. `pnpm --filter @objectstack/spec check:generated` reports 15 of 15 up to date. - Hand-written `content/docs/protocol/kernel/lifecycle.mdx` updated where it names the keys (+8 / −6). ## The authorable-surface ratchet MOVES here, and that is correct `authorable-surface/kernel.json` records **top-level keys per def**. All three of these keys are top level, so the rename must move the ledger — three `[RETIRED]` markers added beside three new suffixed rows, plus the matching `authorable-defaults` moves. This is the opposite reading from sibling PR objectstack-ai#17954, whose key was **nested** (`performance.schemaCacheTTL`) and correctly moved nothing: different readings, same rule, both right. Regenerated by `gen:schema`, never hand-edited. ## Pin tests replaced, not deleted Two minimum-bound pins are the ones worth calling out. Spelled through the bare keys, `PluginHealthCheckSchema.parse({ interval: 500 })` would have kept throwing — off the **tombstone's refusal**, not the `min(1000)` bound, a pin that can no longer fail. They now assert the `too_small` issue code on the suffixed keys plus an accepting control at the boundary. The default and fixture pins were re-pointed at the new spellings with `not.toHaveProperty` on the old ones; nothing was deleted, weakened or skipped. New pins: the refusal carries the rename prescription (asserting the issue is not `unrecognized_keys`), the suffixed keys parse at the magnitude the retired ones carried with the same defaults, the describes publish the unit, and both core classes refuse an old spelling at `registerPlugin` before the plugin is stored, with an accepting control beside each. ## Consumer sweep — `@objectstack/core` is the only reader | subject, outside `packages/spec` and `packages/core` | occurrences | |:--|--:| | `PluginHealthCheckParsed` / `HotReloadConfigParsed` | 0 / 0 | | `PluginHealthCheck` · `HotReloadConfig` · `PluginHealthMonitor` · `HotReloadManager` | 4 · 6 · 2 · 7 — **all prose**: changesets, the generated upgrade guide, a `//` comment in a `packages/rest` pin test, and two CHANGELOG lines. Zero code readers. | | the three keys in a health-check / hot-reload context | 0 | Lit controls on the same corpus: `@objectstack/spec` 14580, `PluginContext` 973, `ObjectKernel` 588. Dark control `zzqqxx` 0. ## Pinned objectui: 0, with controls `.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694` — this IS the pin we build against (checked here). Grepped at that sha across its 6409 tracked files: all **thirteen** exports of `plugin-lifecycle-advanced.zod.ts` occur 0 times, and `debounceDelay` occurs 0 times. Lit controls on the same corpus: `objectstack` 10171, `@objectstack/spec` 3479, `timeout` 832, `interval` 156. Dark control `zzqqxx` 0. Console Pin Gate is unaffected. ## Verification | command | verdict | |:--|:--| | `pnpm --filter @objectstack/spec build` | `VERDICT command-exit 0` | | `pnpm --filter @objectstack/spec check:generated` | exit 0 — 15 of 15 artifacts current | | `pnpm --filter '@objectstack/core^...' build` | `VERDICT command-exit 0` | | `pnpm --filter @objectstack/core build` | `VERDICT command-exit 0` | | `pnpm --filter @objectstack/core test` | `VERDICT command-exit 0` — 51 files, 1316 tests | | `pnpm --filter @objectstack/spec test` | `VERDICT command-exit 0` — 475 files, 13517 tests | | `pnpm --filter @objectstack/spec --filter @objectstack/core typecheck` | `VERDICT command-exit 0` | | `dispatch-gates --commands` → all 110, reconciled with `--ran … :: exit N` | **104 exit 0 · 0 red · 6 exit 3** | The six exit-3s are `PREREQUISITE NOT MET` on unbuilt sibling workspace packages outside this change's build closure (`@objectstack/lint`, `@objectstack/formula`) — NOT MEASURED by the gates' own classification, and built by CI. `check:skill-examples` initially refused with **exit 1** on an unbuilt `@objectstack/client-react` — the refusal shape that a `--ran` reconciler counts as run; it was measured properly after building that closure and reads exit 0 (258 prose examples across 3 surfaces). ## Acceptance notes - **`HotReloadConfig.shutdownTimeout` is deliberately not renamed with these three.** Its JSDoc reads "Graceful shutdown timeout" and names no unit anywhere — it is the unit-nowhere shape the objectstack-ai#14478 gate leaves outside its verdict by measurement, and it is not one of the 21 rows PR objectstack-ai#17635 enumerates. Noted, not filed: the gate's own `--list` census already keeps it visible, and its header records that closing that class is a describe-by-describe decision rather than a defect. - `check-widening-tells` raising T1 on the `retiredKey()` tombstone lines is the known inverted false positive (objectstack-ai#17955) — the lines it fires on make the accept set strictly narrower. The diff is not reshaped and the checker is not weakened. - **Docs Drift Check named six pages; five are false positives.** Its anchor is a bare `timeoutMs` string literal inside the new retirement constant, so it listed every page using that generic word: `automation/jobs.mdx`, `automation/webhooks.mdx`, `automation/flows.mdx`, `deployment/environment-variables.mdx` and `automation/hook-bodies.mdx` — job scheduling, webhook retry backoff and the ADR-0102 sandbox CPU budget, none of them `PluginHealthCheck` or `HotReloadConfig` (0 plugin-health mentions each; `hook-bodies.mdx`'s two "hot-reload" hits are Studio in-browser editing). The sixth, `protocol/kernel/lifecycle.mdx`, is the genuine hit and is edited here. The three `content/docs/releases/` pages are release-owned and untouched. Recorded because the four remaining rename cards in this epic will trip the same anchor. ⛔ Draft on purpose: the in-seat clause-② review at tier is owed first, and landing is the PM's step. --- _Generated by [Claude Code](https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt)_ Co-authored-by: Claude <noreply@anthropic.com>
… unit in the key name (objectstack-ai#17782, ruling A on objectstack-ai#15939) (objectstack-ai#17999) Fixes objectstack-ai#17782 Clause-②: yes Ruling A on objectstack-ai#15939 (director seat 2026-09-11, maintainer 「同意」, decision batch objectstack-ai#115), per-file remediation of the objectstack-ai#14478 rule. This is the `system/logging.zod.ts` card — 4 rows of the 21-row delta enumerated in PR objectstack-ai#17635, which lands LAST. ## What changed | def | before | after | nesting | |:--|:--|:--|:--| | `HttpDestinationConfig` | `batch.flushInterval: 5000` | `batch.flushIntervalMs` | nested under `batch` | | `HttpDestinationConfig` | `retry.initialDelay: 1000` | `retry.initialDelayMs` | nested under `retry` | | `HttpDestinationConfig` | `timeout: 30000` | `timeoutMs` | **top-level** | | `LoggingConfig` | `buffer.flushInterval: 1000` | `buffer.flushIntervalMs` | nested under `buffer` | Every value, default and bound is unchanged. Each old spelling stays as a `retiredKey()` tombstone; none of the four enclosing objects is `.strict()`, so a bare deletion would have stripped the value in silence. ## The JSDoc I read on each key, and what the published channel said All four named milliseconds in a source JSDoc — "Flush interval in milliseconds", "Initial retry delay in milliseconds", "Timeout in milliseconds" — and **none carried a `.describe()` at all**. The `check:duration-unit-keys` census on this tree read all four as `[name: -] [prose: -]` before the change, so `content/docs/references/system/logging.mdx` published a bare 5000 / 1000 / 30000 / 1000 with nothing on the page to decide milliseconds from seconds. Each key is renamed **and** given the describe it never had: under the objectstack-ai#14478 rule, fixing only the describe would itself be a violation. ## The two traps this card carried **`flushInterval` is declared twice, in two different defs, with two different defaults** — 5000 on the HTTP destination's `batch`, 1000 on the logging `buffer`. They are two keys. Each gets its own tombstone, its own `RETIRED_KEYS_BY_MAJOR[18]` row, and a prescription that names its def so an author who lands on one is not sent to the other. Located by def, not by key name. **Mixed nesting, so the ratchet moves for exactly one key.** `authorable-surface` records top-level keys per def (`build-schemas.ts` reads `schema.properties` one level deep). `HttpDestinationConfig.timeout` is the only top-level key of the four, so it is the only one whose rows move: `system/HttpDestinationConfig:timeout` becomes `[RETIRED]` beside a new `system/HttpDestinationConfig:timeoutMs`, and the `authorable-defaults/` row is renamed with it. The three nested keys move neither file — correct, not an omission. `authorable-surface.base.json` is untouched (it is written only by `gen:authorable-surface-base`). ## Naming `Ms`, derived from the family on this tree rather than from the card: 272 key-position `*Ms:` declarations in `packages/spec/src` against 75 `*Seconds:`. The only competing unit spellings are 3 `*MS:` and 9 `*Millis:`, and every one mirrors a name fixed outside this repo — MongoDB's `maxCommitTimeMS` / `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` / `connectionTimeoutMillis` on `PoolConfigSchema` — so unlike the `Ttl`-versus-`TTL` question a sibling round settled, there is no in-repo alternative to choose between. All three target spellings were already attested as key-position `*.zod.ts` declarations: `flushIntervalMs` 1 (on `kernel/events/integrations.zod.ts`, at the same 1000 default), `initialDelayMs` 5, `timeoutMs` 30. ## Changeset level `.changeset/17782-logging-duration-units.md` declares `"@objectstack/spec": minor`, a `feat(spec)!` summary, a `**BREAKING**` banner and the `adr-0087: registered logging-durations-unit-in-key` disposition. Ruling A wrote `patch`; that half of the clause is falsified by the precedent the same sentence names — every `objectstack-ai#15678` / `objectstack-ai#15679` entry in `packages/spec/CHANGELOG.md` under `## 17.4.0` is `feat(spec)!` at minor — and the correction is recorded publicly on objectstack-ai#15939 (`5652120294`). The declaration is `Clause-②: yes` for the same mechanical reason: a rename puts a new authorable spelling on a published payload, and the floor in `references/contract-review.md` makes that a mandatory affirmative whatever the round's size. ## Registration One ADR-0087 D3 semantic entry `logging-durations-unit-in-key` plus four `RETIRED_KEYS_BY_MAJOR[18]` rows, one per key — the shape settled for the four remaining rename cards on objectstack-ai#15939. No D2 conversion: `stack.zod.ts` declares no logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no rehydration seam that runs on an authored logging document. Registry regenerated with `gen:migration-registry`, never hand-merged. Set-difference over every registry row (every `id:` line plus every lone quoted array element), measured three ways: | comparison | rows | lost | gained | |:--|:--|--:|:--| | my regeneration, before → after | 1624 → 1629 | **0** | my 1 semantic id + 4 retired-key rows | | the two `os-regen-merge.sh` syncs of `main`, pre → post regeneration | 1631 → 1631 and 1631 → 1633 | **0** | only what `main` brought (objectstack-ai#17784, objectstack-ai#17781) | | merge base `ae1b258889` → head | 1628 → 1633 | **0** | exactly my 5 rows | Exact-name occurrence counts on the regenerated registry after the second sync, guarding the silent drop the `os-regen` driver can produce: sibling objectstack-ai#17781's `kernel-runtime-config-timeout-unit-in-key` 1 and its row `kernel/RuntimeConfig:resourceLimits.timeout` 1; sibling objectstack-ai#17784's `tenant-schema-cache-ttl-unit-in-key` 1 and `system/SchemaLevelIsolationStrategy:performance.schemaCacheTTL` 1; my own id and four rows 1 each; dark control 0. Both siblings' implementation bodies survive too (`timeoutMs` 7 in `kernel/plugin-security-advanced.zod.ts`, `schemaCacheTtlSeconds` 3 in `system/tenant.zod.ts`). ## Cross-repo The pinned objectui checkout at `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 6409 tracked files, against lit controls `useState` 2304 and `timeout` 702 on the same corpus. Nothing in the sibling reads these keys. ## Tests All readings below are from head `a5976809c2`, the head this PR now carries, re-run after both `os-regen-merge.sh` syncs of `main`. - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/system/logging.test.ts` — 46 passed. Three existing pins asserted the retired spellings (`config.timeout` 30000, `batch.flushInterval` 10000, `buffer.flushInterval` 2000); each was **moved to the new key**, its comment rewritten to say why, and none was deleted or weakened. - `pnpm --filter @objectstack/spec typecheck && pnpm --filter @objectstack/spec test` — VERDICT command-exit 0, 476 files / 13533 tests passed. - `pnpm --filter @objectstack/spec check:generated` — **15 of 15 artifacts up to date** on this head, with the working tree clean, after `gen:migration-registry`, the package build and `gen:docs`. - `node scripts/pm/dispatch-gates.mjs --commands` derives **109** families for these 12 paths; `--ran` with a recorded exit code per command reconciles **109 accounted, 107 run green, 2 NOT MEASURED, 0 unrun**. The two are `check:dual-build-cjs-loads` and `check:type-check-debt`, both exit **3** `PREREQUISITE NOT MET` — they need every workspace package built, which CI's `Build Core` supplies and a spec-only worktree does not. ⛔ Neither is a pass. - `pnpm --filter @objectstack/spec check:duration-unit-keys` — 0 offenders; the four rows leave the census's unit-nowhere population. - `pnpm --filter @objectstack/spec check:objectui-pin-citations` — **exit 0**: 16 asserting citations match `.objectui-sha` (53ded82bf), 26 historical recorded and not checked. The asserting citation sits on ONE physical source line in both its sites (the semantic entry and its generated registry mirror), which is the failure three sibling PRs hit. - `node scripts/pm/check-widening-tells.mjs --declaration yes` — **exit 0**. Under a hypothetical `--declaration no` it exits 4 with **4 T1 tells, one per `retiredKey()` line** (:326 :351 :371 :759) — the inversion already filed as objectstack-ai#17955, and the four newly writable `*Ms` keys are not what it flags. ⛔ The diff is not reshaped and the checker is not weakened. - **Ablation**, from the committed state, with an `EXIT INT TERM` restore trap. All four `retiredKey(...)` calls replaced by a marker comment: occurrence counts 1/1/1/1 → 0/0/0/0 and the injected marker 0 → 4, `git hash-object` `9768a4bb…` → `40095a54…` (the mutation proven on disk, not inferred from an editor exit code). The suite then read **4 failed | 42 passed** — exactly the four `REFUSES the retired spelling` pins, no others. Restored with `git checkout HEAD -- ` on an absolute path: `git hash-object` back to `9768a4bb…` and `git diff HEAD` empty. That blob is still the one at this head, so the reading stands for the PR as it is. ## Landing order, for whoever lands after me This diff edits `packages/spec/authorable-surface/system.json` (+2/−1) and `packages/spec/authorable-defaults/system.json` (±1), and card objectstack-ai#17783 (`system/metrics.zod.ts`, two top-level rows) is in flight against the same two shards. Both paths are routed `merge=os-regen` in `.gitattributes`, so the local driver defers them and `pre-commit` collects the debt — the merge queue rebuilds server-side where no driver runs, so the shard is textually shared and a conflict there is expected, not a defect. The only correct resolution is `bash scripts/pm/os-regen-merge.sh` (commit the merge FIRST, then regenerate on the merged base), ⛔ never a hand-merge of the JSON: these rows are a sorted set, a dropped line is silent, and `gen:schema` recomputes the whole shard from the merged Zod graph anyway. One thing measured here that the later lander can rely on: the ratchet moved for exactly one of my four keys, so the regenerated shard differs from `main` by three lines and nothing else on this file is mine. ## Acceptance notes - `packages/spec/src/system/logging.zod.ts:298` `backoffMultiplier` also reads `[name: -] [prose: -]` in the census, but it is a ratio, not a duration, and is not one of this card's four rows. Noted, not filed. - Six gate families in this diff's derived set refuse with `PREREQUISITE NOT MET` on a worktree that has not built the whole repo; they are named in the report comment as NOT MEASURED rather than passed. CI's `Build Core` supplies what they need. ⛔ Not ready for review by this seat: the in-seat clause-② review at tier is the PM's step. Draft, not enqueued, no auto-merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…t in the key name (objectstack-ai#17783) (objectstack-ai#18007) Fixes objectstack-ai#17783 `Clause-②: yes` Executes director-seat **ruling A on objectstack-ai#15939** (2026-09-11, maintainer 「同意」, decision batch objectstack-ai#115) for `packages/spec/src/system/metrics.zod.ts` — the sixth of the seven per-file remediation cards. PR objectstack-ai#17635 (the gate) lands last, into a tree these cards have cleaned. ## The five rows, re-located by symbol path Line numbers from objectstack-ai#17635's enumeration rot; each row was re-located by symbol and its JSDoc read. | symbol path | JSDoc said | describe said | after | ratchet | |:--|:--|:--|:--|:--| | `MetricDefinition.summary.maxAge` | "Max age of observations in seconds" | *(none)* | `maxAgeSeconds` | nested — moves nothing | | `ServiceLevelObjective.errorBudget.burnRateWindows[].window` | "Window size in seconds" | "Window size" | `durationSeconds` | nested, array element — moves nothing | | `MetricExportConfig.interval` | "Export interval in seconds" | *(none)* | `intervalSeconds` | **top level — moves** | | `MetricsConfig.collectionInterval` | "Collection interval in seconds" | *(none)* | `collectionIntervalSeconds` | **top level — moves** | | `MetricsConfig.retention.period` | "Retention period in seconds" | *(none)* | `durationSeconds` | nested — moves nothing | Every value is seconds as before; every default (600, 60, 15, 604800) is unchanged. Four of the five carried **no `.describe()` at all**, so the reference page published a bare integer. ## Decoys — measured, and none of them moved Measured at `fc28c1d38`, occurrences via `grep -o | wc -l` (`grep -c` counts lines): | key | key-position declarations | of which decoys | |:--|--:|:--| | `window` | 3 | `:346` `MetricAggregationConfig.window` and `:442` `ServiceLevelIndicator.window`, both `z.object({` | | `period` | 2 | `:509` `ServiceLevelObjective.period`, a `z.object({` | | `maxAge` · `interval` · `collectionInterval` | 1 each | — | Lit control on that file `z.number` = 38; dark control (fabricated token) = 0. All three decoys are objects that already hold a `durationSeconds` of their own from objectstack-ai#15679, and a pin in this PR asserts none of them moved. ## Suffix: `Seconds`, and why three of the five are not the mechanical name Counted in key position across `packages/spec/src/**/*.zod.ts` at `fc28c1d38`: **`Seconds` 40 · `Sec` 1 · `S` 0**. The single `Sec` is `maxExecutionTimeSec`; the two bare `*S:` hits on the corpus are `maxCommitTimeMS` (a millisecond spelling) and `enableRLS` (a boolean), so as a *seconds* suffix `S` reads 0. At this PR's head `Seconds` is 45 — the five added here. This file already has a **stated, reasoned naming convention** from objectstack-ai#15679, recorded in its two tombstone entries, and counting alone cannot see it: - **`burnRateWindows[].window` → `durationSeconds`**, not `windowSeconds`. It is the *fourth* window length on this file; objectstack-ai#15679 renamed the other three to `durationSeconds` precisely "so the three measurements now read alike", and rejected `windowSeconds` because the parent key was already `window`. The same stutter applies here against the enclosing `burnRateWindows` array. Independently: on this tree `windowSeconds` is **not an authorable key at all** — its only key-position occurrence is `system/stack-server.zod.ts:88`, an entry in `ServerRateLimitConfigSchema`'s `aliases` map that maps the spelling **away** to `windowMs`. - **`retention.period` → `durationSeconds`**, not `periodSeconds`. `period` is calendar vocabulary elsewhere in this spec (`ServiceLevelObjective.period.type` selects rolling or calendar; `PluginRegistryEntry.pricing.billingPeriod` is monthly or yearly), so `periodSeconds` keeps the ambiguous half of the name — the objection objectstack-ai#15679 raised against `sizeSeconds`. `periodSeconds` is attested 0 times on this tree; `durationSeconds` 4. - **`collectionInterval` → `collectionIntervalSeconds`**, keeping the qualifier, because `MetricExportConfig.intervalSeconds` is a different cadence one def over that this same PR creates. The qualifier-plus-`IntervalSeconds` compound is attested: `syncIntervalSeconds`, `refreshIntervalSeconds`, `healthCheckIntervalSeconds`. The two mechanical spellings are attested rather than invented: - **`maxAgeSeconds`** — `AccessControlConfig.maxAgeSeconds` on `system/object-storage.zod.ts` is the landed rename of a `maxAge` under this same rule, tombstone and all. It also keeps the `age` stem that the sibling key `ageBuckets` counts buckets of; `durationSeconds` would have orphaned that pair. - **`intervalSeconds`** — 4 key-position occurrences, every one a seconds-valued cadence. ## The pin this card was written to trip `metrics.test.ts` carried `it('leaves the two non-duration keys on this file alone')`, holding the burn-rate `window` bare because it is "outside the gate population entirely". **This card is the sweep that guard was written to catch, so it succeeds by failing.** It bundled two subjects; the exporter batch `size` half is a COUNT of records, still true, and survives untouched under a header narrowed to name only it. The `window` half is replaced by a refusal pin, an acceptance pin at the same 3600, and a describe pin, in a block whose own header stays true. ⛔ Nothing deleted, skipped or loosened.⚠️ Note for whoever rebases **PR objectstack-ai#17635**: its diff patches exactly those comment lines (`- outside the gate population entirely` → `+ inside the gate's census and outside its verdict`), and its own text says the pin "must be re-read, not trusted, when the rename lands". That hunk has no subject after this PR; expect a conflict there and drop that half of its repair. ## Kit - `retiredKey()` tombstone per key (⛔ none of the five enclosing shapes is `.strict()` — measured: 0 `.strict()` on this file — so a bare deletion would silently strip). Every tombstone const is declared **above** the schema that reads it: `gen:schema` and `check:authorable-surface` run with `OS_EAGER_SCHEMAS=1`, which makes `lazySchema` evaluate at module load, and a const below its reader is a TDZ read (the trap PR objectstack-ai#17983 hit). - One ADR-0087 D3 semantic entry `system-metrics-jsdoc-durations-unit-in-key` + five `RETIRED_KEYS_BY_MAJOR[18]` rows. It opens by stating how it relates to objectstack-ai#15679's `system-metrics-window-durations-unit-in-key` rather than rewriting it. - **objectstack-ai#15679's entry gains a pointer clause** (contract review, `bcfdd35609`). Its `acceptanceCriteria` ended "Both keep their names." — and that field's contract is *how the consumer proves the hand-migration correct*, rendering as "Done when:" in the guide and "verify:" in `migrate meta`, so a normative sentence saying the burn-rate window keeps its name instructs a future reviewer that this correct sweep was an error. Every existing word is left in place and a clause is appended naming the successor (Prime Directive objectstack-ai#13 pointer, not a rewrite). This is not amending published history: the protocol-18 step is unreleased and still assembling — `PROTOCOL_VERSION` is `17.0.0`, `docs/protocol-upgrade-guide.md` ends at "Protocol 16 → 17", and **both** entry ids render 0 times in the guide, re-measured on this head. - No D2 conversion: `stack.zod.ts` declares no metrics collection and none of these defs is a stored `sys_metadata` row — the reading objectstack-ai#15679 already recorded for this file. - `minor` changeset with the `**BREAKING**` banner, FROM → TO for all five, and `adr-0087: registered`. ## Verification | reading | exit | note | |:--|--:|:--| | `pnpm --filter @objectstack/spec build` | 0 | via `os-verify-lock.sh`, `VERDICT command-exit 0` (latest on head `84e62ed712`) | | `pnpm --filter @objectstack/spec test` | 0 | 476 files, 13536 tests | | `pnpm --filter @objectstack/spec typecheck` | 0 | | | `check:generated` | 0 | 15/15 artifacts current, re-run on head `84e62ed712` after the pointer commit | | `dispatch-gates` derived families | 109/109 exit 0 | derived and run on `bcfdd35609`; the only change since is the objectstack-ai#15679 pointer clause and its `registry.ts` mirror | | consumers: `core` · `verify` · `cli` · `rest` typecheck | 0 each | on a **built** closure | | consumers: `observability` · `downstream-contract` · `http-conformance` test | 0 each | | | pinned objectui at `.objectui-sha` | 0 hits | all six def names + both distinctive keys, 6409 tracked files; lit `window` 2710 · `timeout` 832 · `period` 160 · `interval` 156 · `metrics` 301; dark 0 ⇒ **no pin bump owed** | **Consumers.** Outside `packages/spec`, every occurrence of every distinctive key on these shapes (`burnRateWindows`, `errorBudget`, `downsampling`, `collectionInterval`, `cardinalityLimits`, `maxLabelCombinations`, `ageBuckets`) is in the generated `content/docs/references/system/metrics.mdx`, which this PR regenerates — lit control `defineStack` 1195 on the same corpus, dark 0. Zero in-repo code consumers, confirming the dispatch's own measurement. **Ablation** (both hardest pins, `EXIT INT TERM` trap, byte-identity proven): | leg | anchor before → after | mutated blob ≠ HEAD | tests | restore | |:--|:--|:--|:--|:--| | delete `window: retiredKey(SLO_BURN_RATE_WINDOW_RETIRED),` | 1 → 0 | `8768749…` ≠ `a747965…` | **exit 1**, 1 failed / 45 passed | blob back to `a747965…`, `git diff HEAD` empty, `git status --porcelain` empty | | delete `period: retiredKey(RETENTION_PERIOD_RETIRED),` | 1 → 0 | `8a84628…` ≠ `a747965…` | **exit 1**, 1 failed / 45 passed | same | | control (unmutated) | — | — | **exit 0**, 46 passed | — | **The two legs fail differently, and prove different things** — corrected here after the contract review caught the body claiming they proved the same one: - **Leg 1** reads `AssertionError: expected undefined to be defined` at `metrics.test.ts:622`. `result.success` was still `false`, but no issue landed at `errorBudget.burnRateWindows.0.window`: the sibling `durationSeconds` on that array element is **required**, so with the tombstone gone the parse is refused anyway for a missing required key. What leg 1 proves is the **lost prescription** — the author gets a bare "required" refusal instead of the FROM → TO rename message the tombstone carries. ⛔ It is not a silent-strip demonstration. - **Leg 2** reads `AssertionError: expected true to be false` at `metrics.test.ts:659` — `result.success` was `true`. `retention.durationSeconds` is `.optional().default(604800)`, so with the tombstone gone the unknown `period` key is accepted and stripped and the parse succeeds. **This is the ADR-0049 silent strip**, live. Both legs are valid evidence that their pin can fail, which is what an ablation is for. Leg 1 also answers Zone 2.4's first question by test rather than assumption: `retiredKey()` on an **array-element** object refuses exactly as it does on a plain nested object, at path `errorBudget.burnRateWindows.0.window`. **Second-lander merge.** `bash scripts/pm/os-regen-merge.sh` (⛔ never a hand-merge) after objectstack-ai#17999 landed. Step 2 took main's side of both shards; the regeneration commit rebuilt them from the merged source. Set-difference over the whole registry across the merge: retired-key rows **175 → 182, 0 lost, 7 gained**; semantic ids **210 → 213, 0 lost, 3 gained**. Those totals are `gen:migration-registry`'s own printed line, not a hand-rolled census — an earlier revision of this body carried 177 → 184 and 209 → 212 from a regex over `registry.ts`, which miscounts; the review could not reproduce them and was right. The **deltas** were identical under both methods, and are corroborated a third way by file count: `entries/retired-keys` holds 177 files on `main` @ `8261ff7171` + this card's 5 = 182, and `entries/semantic` 212 + 1 = 213 — so nothing was dropped. objectstack-ai#17782's four logging rows and its `logging-durations-unit-in-key` id re-assert at 4 / 2 / 2 / 4 / 10 — matching the pre-merge baseline exactly — with lit control `kernel-runtime-config-timeout-unit-in-key` 4 and dark control 0. **Array path notation** — `system/ServiceLevelObjective:errorBudget.burnRateWindows.window` uses plain dots with no bracket token. Settled by the contract review, which closed the evidence gap I had declared: the two `change-management.zod.ts` precedents do cross an array element with plain dots (reachable at `bf1054a4c0` despite the shallow clone), and there is a **live** precedent I had missed — `kernel/Manifest:contributes.kinds.globs`, where `kinds: z.array(strictObject({` contains `globs: retiredKey(`. ## Acceptance notes - **`check-widening-tells` T1 on `retiredKey()` lines** — the known inverted false positive objectstack-ai#17955. Not reshaped, not weakened. In this run the whole derived family exited 0. - **`resolution` is a sixth JSDoc-only duration on this file, and no gate will ever say so.** `MetricsConfig.retention.downsampling[].resolution` — JSDoc "Resolution in seconds", describe "Downsampled resolution" — is outside `check:duration-unit-keys` on the **name** axis (`resolution` is not in `DURATION_SHAPED_TOKENS`), so unlike objectstack-ai#15939's prose axis, PR objectstack-ai#17635's widening does not reach it. Not folded in here: triage certified this batch as exactly 21 rows with "⛔ 无第八张". Filed as objectstack-ai#18030. ⛔ Draft. Not ready, not enqueued, no auto-merge. The in-seat clause-② contract review is owed first and landing is the PM's step. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ir unit in the key name (objectstack-ai#17785) (objectstack-ai#18016) Fixes objectstack-ai#17785 `Clause-②: yes` — each rename puts a spelling on `OpenTelemetryCompatibility` and `TracingConfig` that no author could write before. `minor`, `feat(spec)!`, with an `adr-0087: registered` disposition (epic settlement on objectstack-ai#15939, correcting Ruling A's own `patch`). Executes director-seat **ruling A on objectstack-ai#15939** (2026-09-11, maintainer 「同意」, decision batch objectstack-ai#115), the per-file remediation of the objectstack-ai#14478 rule — the **seventh and last** of that ruling's seven cards. ## The four rows Re-located by symbol path on `origin/main` @ `98bd7986fe`; line numbers came from PR objectstack-ai#17635's enumeration and are re-derived here, not trusted. | symbol path | before | after | default | |:--|:--|:--|--:| | `OpenTelemetryCompatibility.exporter` | `timeout` | `timeoutMs` | 10000 | | `OpenTelemetryCompatibility.exporter.batch` | `exportTimeout` | `exportTimeoutMs` | 30000 | | `OpenTelemetryCompatibility.exporter.batch` | `scheduledDelay` | `scheduledDelayMs` | 5000 | | `TracingConfig.performance` | `exportInterval` | `exportIntervalMs` | 5000 | Each declared exactly once in key position, lit control `z.number` 20 on the file, dark control 0. **All four carried no `.describe()` at all** — not "a describe that names no unit", but none — so the published reference row was a bare integer with no unit anywhere on the page. Values, defaults and `int().positive()` bounds are unchanged. ## The suffix is `Ms`, settled from tree counts Key position across `packages/spec/src`: **281** `*Ms` declarations over 42 distinct names — `timeoutMs` 65, `intervalMs` 14, `durationMs` 73 — against **0** key-position `timeoutSeconds` and 77 `*Seconds` of any name. `exportTimeoutMs` / `scheduledDelayMs` / `exportIntervalMs` have 0 competing spellings of any form. The Delay-plus-`Ms` pairing is already attested (`maxDelayMs`, `initialDelayMs`, `retryDelayMs`, `delayMs`, `debounceDelayMs`). This file's own landed precedent is `Span.duration` to `durationMs` (objectstack-ai#15679) — **the opposite of the sibling metrics card, whose rows were seconds**; every JSDoc here was read individually and every one says milliseconds. **The two `*TimeoutMs` keys one nesting level apart — accepted, and why.** The near-duplicate pre-exists the rename: the `batch` sub-object is the OpenTelemetry batch span processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) sitting beside the exporter's own request deadline. Renaming either to something more distinctive would depart from the vocabulary this shape mirrors, and the nesting already disambiguates every read point — `exporter.timeoutMs` versus `exporter.batch.exportTimeoutMs`, never interchangeable because they live in different objects. Rejected alternative: `requestTimeoutMs` for the outer one, which would have invented a spelling OTel does not use to solve a problem the nesting already solves. ## The ratchet moves nothing — and that is the correct outcome, not an omission Verified from the generator source, not assumed. `packages/spec/scripts/build-schemas.ts:852` reads `schema.properties` **one level deep** and records `${defKey}:${name}`, so only top-level keys per def ever enter `authorable-surface/` or `authorable-defaults/`. Corroborated on the committed artifacts: `system/OpenTelemetryCompatibility:exporter` is a single row (the whole nested object), there are **0** dotted keys anywhere in `authorable-surface/system.json` and **0** in `authorable-defaults/system.json` against 247 total keys there, and the lit control `system/Span:duration [RETIRED]` is present. All four of my rows are nested. `check:authorable-surface` is **green without any regeneration**, and `git diff --name-only` against the base matches 0 files under `authorable-surface` or `authorable-defaults`. This is the objectstack-ai#17954 reading, not objectstack-ai#17986's. ## The pin guard this card was written to trip `packages/spec/src/system/tracing.test.ts:545` — `it('leaves the OTel exporter timeout alone — its describe names no unit, so it is outside the population')`. **It succeeds by failing**: its key, its "names no unit" clause and its "outside the population" clause all go false here. It is neither deleted, skipped, weakened nor fixed green — it is **replaced and relocated** out of a `describe` headed `Span.duration carries its unit (objectstack-ai#15679)`, which is no longer its subject, into a new `objectstack-ai#17785` block carrying the three-part shape objectstack-ai#15679 itself set on this file: a refusal pin per row asserting the issue **code** (`not.toBe('unrecognized_keys')`) and the FROM to TO prescription, an acceptance pin at each retired key's magnitude with the same default, a bounds pin, and a describe pin proving the unit now reaches the published channel. ## Stale-prose sweep (`file:line`) Swept `packages/spec/src`, `packages/spec/scripts` and `content/docs` for sentences this card falsifies (`outside the population` · `names no unit` · `no unit at all` · `left alone` · `leaves ... alone` · `untouched here` · `stays bare`), then narrowed to hits whose SUBJECT is one of my four keys. Lit control `unit` 2308 on that corpus, dark control 0. | `file:line` | disposition | |:--|:--| | `packages/spec/src/system/tracing.test.ts:545` | this card's — replaced and relocated, above | | `packages/spec/CHANGELOG.md:2866` | **left alone, reported.** "`ServiceLevelObjective.errorBudget`'s burn-rate `window` and the OpenTelemetry exporter `timeout` name no unit anywhere in their prose, so both are outside the gate's population entirely." Two limbs go false — mine here, the `window` one at objectstack-ai#17783. `packages/*/CHANGELOG.md` is RELEASE-OWNED (AGENTS.md): never edited in a code PR; a factual error in a released entry is amended in a dedicated docs-only PR. It is also jointly owned by a sibling card, so it is not this PR's sentence to rewrite. Raised in the report. | | `packages/spec/src/migrations/entries/semantic/18.system-tracing-span-duration-unit-in-key.ts` | **left alone, deliberately.** Its "it is the only offender on its file" is scoped to ruling B's describe-channel population, which never contained these four. Per the epic settlement, a predecessor's semantic entry is never amended; this round's entry instead opens by saying how it relates to it. | ## Consumers **Zero in-repo readers outside `packages/spec`.** `OpenTelemetryCompatibility`, `TracingConfig`, `exportTimeout`, `scheduledDelay` and `exportInterval` each occur **0** times across the whole tree at `98bd7986fe` excluding `packages/spec` **and** `content/docs/references`, against a lit control of **18920** `Schema` occurrences on exactly that corpus — both counts from one `git grep -o` over that ref carrying both pathspec exclusions — and a dark control of 0. The named consumer packages were each run anyway rather than reasoned about — including the package being edited, the objectstack-ai#17986 lesson. **Pinned objectui**, `.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694`, 6409 tracked files: all **37** exports of `tracing.zod.ts` and each of the four key names occur **0** times. The 404 `Span` and 40 `SpanSchema` raw hits are objectui's own HTML text-span component (`TextSpanSchema`) — a different name, read rather than counted. Lit controls `objectstack` 10171 and `@objectstack/spec` 3479 on the same corpus; dark control 0. **Rollback** is therefore consumer-free: reverting this PR restores the four bare spellings and drops four tombstones no caller in this repo, and none in the pinned sibling, reads. ## The kit - `retiredKey()` tombstone per row — none of these shapes is `.strict()`, so `unrecognized_keys` was never the alternative: a bare deletion is an ADR-0049 silent strip landing a default on an exporter deadline and a background export period - ADR-0087 D3 semantic entry `system-tracing-otel-exporter-durations-unit-in-key` **plus** four `RETIRED_KEYS_BY_MAJOR[18]` rows (the epic settlement: both, every time) - `registry.ts` regenerated with `gen:migration-registry`, never hand-merged - `content/docs/references/system/tracing.mdx` regenerated with `gen:docs` - `minor` changeset with the **BREAKING** banner, the FROM to TO mapping for all four and the `adr-0087: registered` disposition ## Acceptance notes - The prescriptions carry **no** `os migrate meta` sentence, matching objectstack-ai#17784 and objectstack-ai#17780: the sentence belongs to a surface an ADR-0087 **conversion** covers, and these are D3 semantic entries with no D2 conversion (`stack.zod.ts` declares no tracing collection; a tracing config is never a stored `sys_metadata` row). The class pin `retired-key-migrate-sentence.test.ts` deliberately does not judge a prescription that names no command. - Tombstone prescriptions are written **inline** rather than extracted to a `const`, which is the objectstack-ai#17954 shape on a nested key and sidesteps the `OS_EAGER_SCHEMAS=1` temporal-dead-zone trap objectstack-ai#17983 hit by construction — there is no const to order. - `acceptRetiredDefaultResidue()` was considered and **not** used: all four keys are defaulted, but every landed sibling of this epic tombstoned a defaulted key with plain `retiredKey()`. Noted, not filed — raised as an open question rather than decided here. - `content/docs/references/system/tracing.mdx` gains a `TracingConfig.performance` nested-shape section it did not have; the `exporter.batch` describes do not render a row because the generator's nested-shape rendering stops one level shallower. Generator behaviour, unchanged by this PR. Noted, not filed. Authored by the `os-dev` seat for the objectstack-ai#15939 epic PM, session `session_015c5G6TmpMKgnusmTpD7Ntt` (https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt). --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… the permission-set route (objectstack-ai#17835) Fixes objectstack-ai#16929 Executes **Ruling A** — director seat, decision batch objectstack-ai#121 item 2, comment `5644017943` (2026-09-12), carrying the maintainer's 「同意」. Nothing here re-opens a question that ruling settled; alternatives B / C / E are not revisited. **Clause-②: no** — this is a removal / narrowing. Nothing is widened, so no `needs:contract-review`. PR objectstack-ai#17401's landed half (the two `guidance` prescriptions stopping naming the key) **stands and is not redone**: both prescriptions on `origin/main` already omit it, and this branch leaves their text alone. --- ## The six ruled items, one by one | # | ruled | done | | --- | --- | --- | | 1 | `page.zod.ts`: `assignedProfiles` removed; `profiles` / `assignedTo` become refusals naming the permission-set route; the two guidance strings rewritten | ✅ — with one **route correction**, below | | 2 | `page.form.ts` helpText and its four locale bundles removed | ✅ | | 3 | the three false records corrected in the same PR | ✅ — with one **relocation**, below | | 4 | `major` changeset + an ADR-0087 semantic migration entry; key stripped on `migrate meta --stored` with a structured TODO | ✅ substance;⚠️ the `major` **grade** is refused by a standing repo-wide gate — see *One ruled item the tree refuses* | | 5 | PR objectstack-ai#17401's landed half stands | ✅ untouched | | 6 | `Clause-②: no` | ✅ declared above in the fixed spelling | ### Item 1 — route correction: a `retiredKey()` tombstone, not a bare shape deletion The retirement playbook offers two routes and keys the choice on whether the schema is strict: `retiredKey()` for a non-strict schema, delete-plus-`guidance` for a strict one. `PageSchema` is a `strictObject`, so the first attempt took the strict route — and the build refused it: ``` ❌ 1 authorable key(s) disappeared from the contract: - ui/Page:assignedProfiles ``` `scripts/build-schemas.ts` check (a) is fatal for **any** key that leaves an emitting def, strictness notwithstanding, and check (c) then ratchets a baseline deletion against the merge base on one of three proofs — aged-out tombstone, def unreachable from the metadata-type roots, or whole def gone. `ui/Page` is reachable from the `page` root and keeps emitting, so none holds. The route the tree actually permits here is the tombstone, which is also what the sibling `view.pageName` retirement took two days ago. This is not a softening of the ruling. The key is **unwritable**: `tsc` types it `never`, and a value reaching a parse raises the prescription. It simply stays in the *walked shape*, which is why its liveness row stays (as `dead`) and why the authorable-surface baseline marks it `[RETIRED]` instead of losing the line. ### Item 3 — one of the three false records is not where the ruling says it is Every `path:line` was re-derived by sentence rather than trusted. Two of the three resolved as written; the second did not. | ruled location | what is actually there | | --- | --- | | `packages/spec/liveness/page.json` — `live` citing a non-existent objectui bridge | ✅ **found**, corrected | | `packages/spec/liveness/view.json:125` — the "page audience gate" justification | ❌ **not there any more.** `git grep` over that file finds zero hits for `audience`, `assignedProfiles` or `page audience` (lit control: `pageName` reads 3 lines in the same file; dark control 0). The objectstack-ai#17063 `pageName` retirement rewrote that row on 2026-09-10 and the justification left with it. The same assertion is live at **`packages/spec/src/api/protocol.zod.ts`** (`SearchAllPageHitSchema`'s TSDoc) — that is the one corrected here | | `packages/metadata-protocol/src/protocol.ts` — "enforced at page render" | ✅ **found**, corrected | So the count is still three, and all three assertions are gone; one of them lives at a different address than the ruling recorded. **Before → after, and what makes the new text true.** 1. `packages/spec/liveness/page.json` — was `status: "live"`, note: *"profile-scoped page audience; objectui bridges it (react/src/spec-bridge/bridges/page.ts) to PageLayout.assignedProfiles."* Now `status: "dead"` with a `verifiedAt` and a note recording that the cited path does not exist in objectui (nor does any `spec-bridge` directory), while two sibling objectui citations in the same file resolve. True because the key is now a tombstone and the ledger's own route table says a tombstoned key keeps its row with a `dead` verdict. 2. `packages/spec/src/api/protocol.zod.ts` — was *"where the page's own audience gate (`assignedProfiles`) applies unchanged"*. Now states that a page has no audience gate of its own, that the key which read as one was removed precisely because nothing enforced it, and that what protects a page is the permission sets on the data it shows. True because the key no longer exists and never had a reader. 3. `packages/metadata-protocol/src/protocol.ts` — was *"is enforced where it is enforced now, at page render"*. Now states the opposite and keeps the delegation posture the sweep rests on, which never depended on the key. True by the cross-repo measurement the card and triage both took.⚠️ `packages/metadata-protocol/**` is `domain:engine`'s lane. It is here **only** because ruling item 3 puts all three records in one PR, and exactly one sentence is touched. --- ## Measurement ### The removal is real, and it reaches the built artifact Probed against the **built** `packages/spec/dist/ui/index.mjs` before and after, same script both times. **Before** (`origin/main` content, built): ``` A assignedProfiles :: ACCEPTED :: keys=assignedProfiles,isDefault,kind,label,name,regions,template,type :: value=["admin"] B profiles(alias) :: REFUSED :: code=unrecognized_keys :: path=[] :: Unrecognized key(s) on this page: `profiles`. Did you mean `profiles` → `assignedProfiles`? … C assignedTo :: REFUSED :: code=unrecognized_keys :: path=[] :: Unrecognized key(s) on this page: `assignedTo`. Did you mean `assignedTo` → `assignedProfiles`? … D LIT-CONTROL icon :: ACCEPTED E DARK-CONTROL :: REFUSED :: code=unrecognized_keys ``` **After** (this branch, rebuilt): ``` A assignedProfiles :: REFUSED :: code=invalid_type :: path=["assignedProfiles"] :: `page.assignedProfiles` was removed in @objectstack/spec 18 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (`sys_position_permission_set`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. B profiles(alias) :: REFUSED :: code=unrecognized_keys :: path=[] :: Unrecognized key(s) on this page: `profiles`. • `profiles` / `assignedTo` are not page keys (ADR-0090 D2: no Profile concept). Page audience is the permission set's — gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (`sys_position_permission_set`), never on the page itself. … C assignedTo :: REFUSED :: same sentence D LIT-CONTROL icon :: ACCEPTED (unchanged) E DARK-CONTROL :: REFUSED :: code=unrecognized_keys (unchanged — the generic report, no rename offered) ``` That is the actual refusal text, not a claim that one exists. Note the refusal **moved channel**: `unrecognized_keys` at the page → `invalid_type` located at `["assignedProfiles"]`, which is what a `z.never()` tombstone produces. ### The alias refusals point somewhere true — read, not inherited A previous round on this card asserted that an alias table runs only from the `unrecognized_keys` path. I re-read the source rather than inherit it. `packages/spec/src/shared/strict-object.ts`'s own docblock puts it in terms — *"`aliases` … is consulted BEFORE the distance fallback"* inside `strictUnknownKeyError` — and `shared/alias-integrity.test.ts` states the mechanism as the premise of the gate it implements: *"an alias only ever runs from the `unrecognized_keys` path, so a key the shape declares can never reach it."* **The before-probe is the direct evidence**: rows B and C above are `REFUSED` with `code=unrecognized_keys` **before** any change. So `profiles` and `assignedTo` were never in the accept set, the alias only decorated the rejection, and **deleting or repointing those entries narrows nothing** — same code, same path, different text. My own reading agrees with the earlier round's. A second consequence made the entries impossible to keep: `alias-integrity.test.ts` asserts that an alias's *target* is a key the shape accepts. Once the key is a tombstone, `profiles: 'assignedProfiles'` would point at a key the schema cannot accept — the ledger's finding-7 shape. They had to become guidance. ### The migration entry actually fires Driven over a stored page carrying the key, against the built artifact: ``` === 1. `migrate meta` chain replay (authored source, 17 -> 18) === stack after :: {"pages":[{"name":"deal_desk","label":"Deal Desk","isDefault":false}]} key still present? false applied :: [{"toMajor":18,"conversionId":"page-assigned-profiles-removed","surface":"page.assignedProfiles", "from":"assignedProfiles","to":"(removed)","path":"pages[0].assignedProfiles"}] structured TODO present? true TODO.toMajor = 18 TODO.surface = `page.assignedProfiles` — the per-page audience list (REMOVED) === 2. stored-row rehydration (the `migrate meta --stored` seam) === stored row after :: {"name":"deal_desk","label":"Deal Desk","isDefault":false} key still present? false === 3. CONTROLS === LIT : a page WITHOUT the key is untouched :: {"name":"team_home","label":"Team Home","isDefault":true} DARK: a fabricated key is NOT stripped :: {"name":"p","label":"P","zzqqNeverDeclared":1} ``` The key goes on both seams and the structured TODO appears. The strip is deliberately paired with a D3 semantic entry rather than left to read as "handled": which permission set a given profile name corresponds to is a judgement no walker can derive. ### Ablation — the three new pins, RED before and GREEN after One mutation leg restores both halves of the fix (the live key, and the two alias entries in place of the guidance ones). ``` HEAD blob for packages/spec/src/ui/page.zod.ts = 388c9ee leg 0 GREEN baseline Tests 83 passed (83) leg 1 on-disk proof of the mutation tombstone line 1 -> 0 live-key line 0 -> 1 guidance entries 2 -> 0 blob hash 388c9ee… -> 966b7ef6… (changed, so the write landed) RED × refuses `assignedProfiles` at the tombstone and prescribes the permission-set route × answers `profiles:` with the permission-set route, not the retired key × answers `assignedTo:` with the same sentence as `profiles:` Tests 3 failed | 80 passed (83) ← exactly the three new pins, nothing else leg 2 restore (git checkout HEAD -- <abs path>; trap on EXIT INT TERM) restored blob hash = 388c9ee == HEAD blob git diff HEAD :: '' (empty) git status --porcelain :: '' (empty — index AND tree) GREEN Tests 83 passed (83) ``` Restore is proven by the blob hash against the HEAD blob and by an empty `git diff HEAD` + empty `git status --porcelain`, never by an exit code. No rebuild leg is needed and none is claimed: `page.test.ts` imports `./page.zod` — a relative source path inside the same package — so this ablation never resolves through `dist`.⚠️ One reading in that run is **NOT MEASURED**: the `alias line` occurrence count printed empty because the `grep -c` was mis-quoted inside a double-quoted `$(...)`. The mutation is nonetheless established by the two counts that did fire and by the blob-hash change; a clean re-grep on the shipped file reads **0** alias lines (lit control `aliases: {` = 6, dark control = 0). ### Changeset — it reaches a published `dist`, with both controls Built **first**, then measured (`npm pack --dry-run --json` before a build reads `dist` as empty — that trap is avoided). - **Positive control** — the new prescription is in **18** packed `dist/` files and in `dist/index.d.ts` (3 hits), so a consumer's `tsc` and runtime both see it. `src/ui/page.zod.ts` is itself in the packed list. - **Negative control** — `src/ui/page.test.ts` and `scripts/build-schemas.ts` are **NOT** in the packed list (2012 files packed; 216 `dist/`, 201 `src/`, 38 `liveness/`). -⚠️ A first negative control **failed and is reported rather than swapped out quietly**: a source-only `//` comment in `page.zod.ts` reads 18 hits in `dist/`. `packages/spec`'s tsup build does **not** strip comments, so for this package a comment inside a shipped module is published text. Useful, and worth knowing before writing one. - `packages/metadata-protocol` correctly carries **no** changeset entry: its `files` is `["dist","README.md","CHANGELOG.md"]`, the corrected sentence is an inline body comment, and it reads **0** files in that package's `dist` (lit control `CLOSURE_CONTEXT_KEY_BY_TYPE` = 2, so the instrument fired). --- ##⚠️ One ruled item the tree refuses as written: the `major` grade Ruling item 4 fixes the changeset at `major`. The changeset in this PR **is** `major`, as ruled. A standing repo-wide gate refuses it: ``` $ node scripts/check-changeset-no-major.mjs --base origin/main EXIT=1 ⛔ This PR introduces changeset(s) that declare a `major` bump. .changeset/16929-page-assigned-profiles-removed.md - @objectstack/spec: major Every publishable package is in the Changesets `fixed` (lockstep) group, so a single `major` promotes the ENTIRE monorepo to a new major version. During the launch window ship breaking changes as `minor` instead … If a whole-stack major release is genuinely intended, add the `allow-major` label to the PR to skip this check. ``` So the ruling **can** be executed literally — the gate names its own escape — but the escape asserts *"a whole-stack major release is genuinely intended"*, and that is a release-shaping claim well beyond this card: - every publishable package moves (~70, in lockstep) from `17.x` to `18.0.0`; - `scripts/sync-protocol-version.mjs` keys `PROTOCOL_VERSION` off the spec package major, so the bump also flips the handshake to `18` and activates the **24 other** `toMajor: 18` conversions already waiting in the registry. ⛔ I have therefore **not** applied `allow-major`, and I have **not** silently regraded the changeset to `minor`. The grade stands as ruled and the gate stands red, with its reading recorded here. The remedy is one line and it belongs to the seat or the maintainer: - **keep `major`** ⇒ add the `allow-major` label, and this PR is the one that cuts `18.0.0`; or - **regrade to `minor`** ⇒ the launch-window convention, which the gate's own prose says is carried instead by the **BREAKING** banner and the ADR-0087 disposition — both of which this changeset already has. It is also what the sibling `view-page-mount` retirement (an identical-shape breaking removal, two days ago) did. --- ## The hot registry file — what was taken, and against which tip `packages/spec/src/migrations/registry.ts` is contended by objectstack-ai#17792, objectstack-ai#17638 and objectstack-ai#17635. **No entry number was taken, because there are none.** The contended regions of that file are *generated*: `src/migrations/entries/` holds one file per entry, filename derived from the id, no index, concatenated by `gen:migration-registry` and sorted by id (`entries/README.md` is the authority). This PR adds two such files and **never edits between the markers**: - `entries/retired-keys/18.ui__Page__assignedProfiles.ts` → `RETIRED_KEYS_BY_MAJOR[18]` - `entries/semantic/18.page-assigned-profiles-audience-to-permission-set.ts` → `MIGRATIONS_BY_MAJOR[18].semantic` Two hand-edited lines remain, both appends at the tail and neither renumbering anything: `step18.conversionIds` gains `'page-assigned-profiles-removed'` after `'view-page-mount-removed'`, and `step18.rationale` gains a paragraph. ⛔ No other PR's entry is renumbered or reordered. **The merge, and what it actually collided with.** `origin/main` was merged immediately before opening this PR: merge commit `8774a8c115`, parents `3a1be112ff` (this branch) + **`c1078a5591`** (`origin/main` at that moment). The collision was real but it was **not** a number — it was `step18.rationale`, where objectstack-ai#17260's landed `object-kanban.quickAdd` retirement and this card had each appended a paragraph to the same prose field. Resolved semantically, **both intents kept**, main's paragraph first: - neither paragraph was dropped, shortened or reordered; - the merge also silently dropped main's `"ui/ObjectKanbanProps:quickAdd [RETIRED]"` baseline marker — the os-regen driver's documented exit-0-while-dropping-a-side behaviour. `scripts/pm/os-regen-merge.sh`'s order was followed (merge committed first, regeneration as its own commit, `pre-commit` deferral discharged), and the regeneration put that marker back. That restoration is visible as its own commit; - ⛔ nothing belonging to objectstack-ai#17792, objectstack-ai#17638 or objectstack-ai#17635 was renumbered, reordered or touched. Everything in *Verification* below was re-run **on the merged tree**, at `626ca34833`. --- ## ⭐ T1 of objectstack-ai#17618 — known false positive, recorded in advance This PR declares `Clause-②: no`, which is the only condition under which objectstack-ai#17618's T1 leg fires, and T1 reads a **re-declared** key line as a new key (three false positives to date, most recently objectstack-ai#17796 on a `.describe()` change). This diff re-declares `assignedProfiles` on its existing key line and rewrites alias/guidance entries on existing lines, so it is squarely in T1's blast radius. If T1 reds on a line this PR did not add, **that is the false positive** — the reading goes here, and ⛔ `no` is not flipped to `yes` to clear a gate: the declared value is the criterion, not the diff shape. --- ## Verification Exit codes captured before any pipe. Heavy runs went through `scripts/pm/os-verify-lock.sh` with `OS_VERIFY_LOCK_SLOT=issue-16929`; the verdict quoted is the wrapper's own `VERDICT` line, or a per-part `echo "$?"` marker where parts were sequenced. All of the following at **`626ca34833`**, the merged head. | run | result | | --- | --- | | `pnpm --filter @objectstack/spec build` | `VERDICT command-exit 0` | | `pnpm --filter @objectstack/spec check:generated` | **all 15 artifacts up to date** on the merged tree (an earlier pre-merge run found 2 stale — `content/docs/references/**` and `liveness/state-counts.md` — regenerated with `--fix`) | | spec `test` + `typecheck` + platform-objects `test` + metadata-protocol `typecheck`, joined with `&&` so one verdict covers all four | `VERDICT command-exit 0` — spec: Test Files 471 passed (471), Tests **13375 passed (13375)**; platform-objects: 39 files, 561 tests | | `npx eslint . --no-inline-config --format json` — the **full repo union, no narrowing claimed** | `ESLINT_EXIT=0` — **6647** files received per `--format json`, **0 errors, 0 warnings** | | `node scripts/pm/dispatch-gates.mjs --ran … --repo objectstack-ai/objectstack` | `EXIT=0` — **114 derived, 112 run, 2 NOT-MEASURED, 0 UNRUN** | | `node scripts/check-i18n-bundles.mjs --write` | first run `EXIT=3` = **PREREQUISITE NOT MET = NOT MEASURED** (the built CLI closure was absent); the closure was built (`VERDICT command-exit 0`, 57 tasks) and the re-run is `EXIT=0`, all nine bundles regenerated | | `pnpm check:nul-bytes` | `EXIT=0` — 8463 files scanned, no raw control bytes. Own sweep over the 20 changed paths with `grep -naP` finds none; lit control on an injected byte fires | | `node scripts/check-changeset-no-major.mjs --base origin/main` | `EXIT=1` — **deliberate, see above** | The two NOT-MEASURED families both exited **3 = PREREQUISITE NOT MET**, which is neither a pass nor a finding, and neither is counted green here: - `pnpm check:dual-build-cjs-loads` — reads built output for ten packages this worktree never built (`@objectstack/studio`, `client-react`, four connectors, …); - `pnpm check:type-check-debt` — wants `turbo run build --filter='./packages/*' --filter='./packages/*/*'` first, and its own text says ⛔ no ledger number may be raised on a run that measured nothing. CI's **Build Core** supplies both. Two families that first read red on a stale build were re-run after rebuilding and are green: `check:skill-examples` (`exit 0`, 258 prose examples across 3 surfaces — it had refused on an unbuilt `packages/client-react/dist`, which was then built) and `check:react-declaration-parity`, run **as CI runs it** with `MANIFEST="$PWD/sdui.manifest.json" … --baseline react-declaration-parity.baseline.json --strict` (`exit 0`, *"no new DECLARATION divergence vs accepted baseline"*) — its own refusal text says a complete local run is available from the checked-in root manifest and ⛔ must not be reported as NOT MEASURED. One earlier reading is withdrawn rather than quietly dropped: `pnpm check:query-options-erasure` first read `exit 2`. That run happened while `src/migrations/registry.ts` briefly held a merge-resolution syntax error, and the gate parses that very file. Re-run on the fixed tree it is `exit 0` — *"ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new"*. Regenerated artifacts, each reviewed rather than waved through: - `authorable-surface/ui.json` — `"ui/Page:assignedProfiles"` → `"ui/Page:assignedProfiles [RETIRED]"` - `liveness/state-counts.md` — `page` 23 live → 22 live + 1 dead (total 24 unchanged); repo total 850→849 live, 93→94 dead - `content/docs/references/ui/page.mdx` — the row's type becomes `never` and carries the `[REMOVED]` prescription - the four `*.metadata-forms.generated.ts` bundles lose the `assignedProfiles` block (zh-CN 「指定配置文件 / 此页面对哪些 Profile 可用」, ja-JP「割り当てプロファイル」, es-ES "Perfiles asignados", en) Commit messages were swept for a card relation and a model identifier, each stem counted separately: `fix` / `fixes` / `fixed` / `close` / `closes` / `closed` / `resolve` / `resolves` / `resolved` / `part of` / `refs` and `#`+digits all read **0**; `Opus` / `Sonnet` / `Haiku` read 0. Lit control `Claude` reads 8, so the sweep reaches. The only `claude-` / `anthropic` hits are the mandated trailer pair. ## 验收备注 - `docs/adr/0010-nl-to-flow-authoring.md` mentions `assignedProfiles` in a 2026-era open question. Untouched on purpose: `docs/adr/**` is a governed surface, and one path hit would make this whole diff governed and unmergeable by the queue. Noted, not filed. - `docs/audits/2026-06-pageschema-property-liveness.md` and `docs/audits/2026-06-security-identity-property-liveness.md` both name the key. Untouched: they are dated audit records, and editing them would falsify the record rather than correct it. - `.changeset/page-guidance-stops-prescribing-assignedprofiles.md` (PR objectstack-ai#17401's, still pending) states *"`assignedProfiles` remains an authorable key with its declaration untouched"*. Accurate about that PR; superseded by this one inside the same unreleased window. This changeset says so rather than editing another PR's. Authored by the `domain:spec` execution seat's `os-dev` round, session `session_01MkQhmuuJAVDjmeWNixwDDH`, on branch `claude/issue-16929-assignedprofiles-removal` at `626ca34833`. --- _Generated by [Claude Code](https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #15939
The
check:duration-unit-keyswidening, landing last as ruling A sequenced it. All seven remediation cards (#17780–#17786) have merged, so the population this rule adds is already remediated and the widened gate reads zero offenders on the merged tree.The ruling this executes
Option 2 of #15939, recorded 2026-09-07 (decision batch #65), maintainer 「同意」; sequencing set by ruling A, director seat 2026-09-11, maintainer 「同意」 (decision batch #115):
JSDoc is developer commentary and is not "prose" in the sense of the #14478 rule, so the gate does not start reading JSDoc as a unit channel — option 1 was explicitly not adopted. What it refuses is the divergence: a duration-shaped numeric key whose JSDoc names a unit and whose
.describe()names none (or which has no describe at all).⛔ The JSDoc is read in exactly one direction: to refuse, never to satisfy. A key with no unit in either channel is still listed and still not judged (the #14519 shape, unmoved). Two self-test cases pin that direction and neither was relaxed to reach zero:
What changed while this PR waited
Merged
origin/mainin (⛔ never rebased, never force-pushed — the branch isos-bill's). Merge baseb06b2db5c4, two commits past the6d647858b7the dispatch recorded; both land inpackages/spec/src/and neither introduces an offender.The diff is now 4 files, not 5. #17635's
metrics.test.tshalf was obsolete, not merely conflicted: #17783 rewrote the enclosingit(...), moved the same correction into a narrowed header comment, and renamed the key itself. Verified by content rather than line number — the hunk's exact target text (outside the gate population entirely and keeps its bare name) returns 0 occurrences on the merged tree, while the bare phraseoutside the gate population entirelyreturns 1, now quoted as the superseded reading. Lit controlburn-rate2, dark control (token invented at read time) 0. That half is dropped; nothing is lost.packages/spec/src/migrations/registry.tsis generated and was NOT resolved textually. It text-merged without a conflict, which is exactly the state AGENTS.md §10 says never to trust, so it was regenerated withpnpm gen:migration-registryand proved byte-identical to the merged bytes (blobcef78f2140before and after).check:migration-registrythen read:The only conflict was
metrics.test.ts, resolved to main's side.os-regen-merge.shstopped with "✗ merge stopped on conflicts in NON-generated files — resolve those by hand" — correct for this merge, since the sole conflict really was a hand-written file. The #18047 misclassification did not manifest here.The prose repair — one site, not two
The blindness did not merely miss keys, it produced confident wrong prose about why they were missed. One site still carried it. The replacement text #17635 was carrying had itself gone stale and would have landed already wrong: it described the neighbouring
RuntimeConfig.resourceLimits.timeoutas a key whose "describe names no unit at all", "inside the gate's census and outside its verdict", and "a rename waiting on that gate change". On this tree that key is already renamed —timeoutMs … .describe('Maximum execution time in milliseconds')atplugin-security-advanced.zod.ts:316-317, its tombstone at:320, its retirement prose at:185.The note now repairs the original wrong reason without re-asserting a landed rename as pending, and points at the neighbour's own entry instead of restating its story. It spells no package version, so it does not join the
@objectstack/spec 18class filed as #18040.registry.tsregenerated to mirror it. ⛔ No pin assertion, title or body changed.The second site — the
metrics.test.tsburn-rate pin — was corrected by #17783 when it renamed that key, so nothing is owed there.The gate reading, from the right instrument
⛔
check:duration-unit-keyson plainmainproves nothing here:maincarries the old gate. This is this branch's widened gate on the merged tree:A zero is only as good as the proof the instrument can still say non-zero. Lit control, drawn a different way than the self-test — a real on-disk mutation of a real source file, not a synthetic fixture: the unit was stripped from
resourceLimits.timeoutMs's describe, leaving its JSDoc naming milliseconds. Blob160ab186→b7d06799, deleted anchor0/ injected anchor1. The gate then:The new rule class fires, on the real tree walk, at the exact mutated key, with the census unchanged at 211/2482 — so only the verdict moved. Restored and proved restored by observed state: on-disk blob back to
160ab186,git diff HEADempty, working tree clean. The mutation ran under atrap … EXIT INT TERMwith an absolute path.Population, measured here rather than relayed: 18 renames each carrying a
retiredKey()tombstone entry naming #15939 onorigin/main, plus 3 describe-only corrections = 21. Counted with an instrument independent of the gate (tombstone entry files), lit control 15 (#15678entries), dark control 0.Ruling A's objectui pre-check — inapplicable, and why
Ruling A requires a
git grepofobjectuiat the pinned SHA before landing a published-key rename (AGENTS.md Post-Task Checklist step 4). This PR renames nothing. The 18 renames all landed in the seven remediation cards, each of which owed that check on its own landing. Measured here: every changed line underpackages/spec/src/is a comment — the non-comment changed-line set is empty. So the check is inapplicable rather than done, and it is ⛔ not silently skipped.Verification
Heavy runs through
scripts/pm/os-verify-lock.sh(OS_VERIFY_LOCK_SLOT=issue-15939-dev), verdicts quoted from the lock's ownVERDICT command-exitline, ⛔ never a bare$?.pnpm --filter '@objectstack/spec^...' buildVERDICT command-exit 0— empty closure (No projects matched);packages/spechas no workspace dependencies, so this is a declared no-op, ⛔ not counted as a passpnpm --filter @objectstack/spec buildVERDICT command-exit 0(199s) — run before every gate that readsdist/pnpm --filter @objectstack/spec typecheckVERDICT command-exit 0—tsc --noEmit+check:scripts-typecheck+check:test-typecheckpnpm --filter @objectstack/spec testVERDICT command-exit 0— 476 files / 13578 tests passedcheck:duration-unit-keys --self-testcheck:duration-unit-keys(widened, merged tree)check:migration-registrycheck:generateddispatch-gates.mjs --ranGate family derived after the merge and changeset existed, with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, and reconciled with--rancarrying a recorded exit code per family. Two prerequisites were built rather than counted green (@objectstack/formula,@objectstack/lint,@objectstack/objectql), after whichcheck:doc-formula-expressionsandcheck:lean-entry-closureboth read exit 0.⊘ NOT MEASURED — declared, ⛔ not counted green:
pnpm check:dual-build-cjs-loads— exit 3,PREREQUISITE NOT MET: reads built output for 87 packages and needs a whole-repopnpm build. That is CI'sBuild Core. Nothing was measured; this is neither a pass nor a finding.pnpm check:pm-dispatch-gates— exit 124, my owntimeoutkill at 560s under the container's foreground cap, ⛔ not a gate verdict. Its partial output showed no failing case, but no verdict line was reached. Note the reconciler derives NOT-MEASURED only from exit 3, so it counts this family as run; it is declared here instead.The repo-wide farm (
pnpm lintand the 48 artifact-roster, 11 wide-population and 5 path-scheduled CI families the derivation names as outside its total) is CI's run. This narrowing is declared, not silent.Changeset
.changeset/15939-duration-unit-keys-jsdoc-divergence.md,@objectstack/spec: patch. Rewritten against the merged tree: it had claimed a live "0 → 21 offenders" population delta and "two wrongly-recorded reasons repaired", both false now that the remediation has landed and one of the two sites belongs to #17783. ⛔ No published key, accept set, default or runtime behaviour moves in this diff.Gate declaration for this PR
Why
nois the honest answer for THIS diff, re-measured after the merge: the four files are the gate script (packages/spec/scripts/**, notsrc/**), one comment-only prose repair, the regenerated migration registry mirroring it, and the changeset. Mechanically checked — every changed line underpackages/spec/src/is a comment; the non-comment changed-line set is empty. No new exported symbol, no new key on a published payload, and no accept/reject outcome moved. What the diff does is make an existing gate refuse more inputs, which narrows the accept set; narrowing is the semantic face, never clause ②.check:pm-widening-tellsreads exit 0.packages/spec/), so an in-seat contract review atCONTRACT_REVIEW_TIERis owed before this may turn ready or enqueue. Scheduled, not waived — and this round ⛔ did not turn it ready, ⛔ did not enqueue it, and ⛔ did not touchneeds:contract-review.Prepared by the
os-devseat in sessionsession_015c5G6TmpMKgnusmTpD7Ntt; merge commitd9b3fd5a3d, merge baseb06b2db5c4.Generated by Claude Code