Skip to content

feat(spec): refuse a duration key whose JSDoc names a unit its describe does not - #17635

Merged
zhuangjianguo merged 7 commits into
mainfrom
claude/issue-15939-duration-unit-keys-jsdoc
Sep 13, 2026
Merged

zhuangjianguo merged 7 commits into
mainfrom
claude/issue-15939-duration-unit-keys-jsdoc

Conversation

@os-bill

@os-bill os-bill commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Part of #15939

The check:duration-unit-keys widening, 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):

Ruling A. Batch #65's direction stands (the gate refuses a duration key whose JSDoc names a unit its describe does not). Sequencing: the remediation lands first, per file … and PR #17635 (the gate + self-test + the two corrected prose sites) lands last, into a tree it already reads as clean. ⛔ Not B (18 published-key renames in one PR); ⛔ C is gate weakening.

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:

✓ the JSDoc does NOT satisfy the rule: describe names the unit, name does not → still unit-in-prose-not-in-name
✓ listed, not judged: a JSDoc that names no unit leaves the #14519 shape exactly where it was

What changed while this PR waited

Merged origin/main in (⛔ never rebased, never force-pushed — the branch is os-bill's). Merge base b06b2db5c4, two commits past the 6d647858b7 the dispatch recorded; both land in packages/spec/src/ and neither introduces an offender.

The diff is now 4 files, not 5. #17635's metrics.test.ts half was obsolete, not merely conflicted: #17783 rewrote the enclosing it(...), 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 phrase outside the gate population entirely returns 1, now quoted as the superseded reading. Lit control burn-rate 2, dark control (token invented at read time) 0. That half is dropped; nothing is lost.

packages/spec/src/migrations/registry.ts is 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 with pnpm gen:migration-registry and proved byte-identical to the merged bytes (blob cef78f2140 before and after). check:migration-registry then read:

✓ src/migrations/registry.ts is current (215 semantic, 186 retired-key, 178 retired-def)

The only conflict was metrics.test.ts, resolved to main's side. os-regen-merge.sh stopped 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.timeout as 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 renamedtimeoutMs … .describe('Maximum execution time in milliseconds') at plugin-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 18 class filed as #18040. registry.ts regenerated to mirror it. ⛔ No pin assertion, title or body changed.

The second site — the metrics.test.ts burn-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-keys on plain main proves nothing here: main carries the old gate. This is this branch's widened gate on the merged tree:

✓ check:duration-unit-keys — 211 duration-shaped numeric key(s) across 2482 source file(s) all carry
  their unit in the key name (or in a sibling `unit`, or under a declared exemption: 6 declared
  `EpochMs` instant(s), 11 declared `externalVocabulary` mirror(s)); zero offenders, no baseline.
                                                                                          exit 0

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. Blob 160ab186b7d06799, deleted anchor 0 / injected anchor 1. The gate then:

✗ check:duration-unit-keys — 1 offender(s) among 211 duration-shaped numeric key(s) in 2482 source file(s)
  [unit-in-jsdoc-not-in-describe] packages/spec/src/kernel/plugin-security-advanced.zod.ts:316 `timeoutMs`
  — the JSDoc above the key names ms but the describe names no unit ("Maximum execution time").
                                                                                          exit 1

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 HEAD empty, working tree clean. The mutation ran under a trap … EXIT INT TERM with an absolute path.

Population, measured here rather than relayed: 18 renames each carrying a retiredKey() tombstone entry naming #15939 on origin/main, plus 3 describe-only corrections = 21. Counted with an instrument independent of the gate (tombstone entry files), lit control 15 (#15678 entries), dark control 0.

Ruling A's objectui pre-check — inapplicable, and why

Ruling A requires a git grep of objectui at 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 under packages/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 own VERDICT command-exit line, ⛔ never a bare $?.

check result
pnpm --filter '@objectstack/spec^...' build VERDICT command-exit 0empty closure (No projects matched); packages/spec has no workspace dependencies, so this is a declared no-op, ⛔ not counted as a pass
pnpm --filter @objectstack/spec build VERDICT command-exit 0 (199s) — run before every gate that reads dist/
pnpm --filter @objectstack/spec typecheck VERDICT command-exit 0tsc --noEmit + check:scripts-typecheck + check:test-typecheck
pnpm --filter @objectstack/spec test VERDICT command-exit 0476 files / 13578 tests passed
check:duration-unit-keys --self-test exit 0 — 156 cases, 0 failures; the two one-directional pins quoted above intact
check:duration-unit-keys (widened, merged tree) exit 0, zero offenders, + the lit/dark controls above
check:migration-registry exit 0 — "registry.ts is current (215 semantic, 186 retired-key, 178 retired-def)"
check:generated exit 0 — all 15 generated artifacts up to date
dispatch-gates.mjs --ran 82 derived, 81 run, 1 NOT-MEASURED, 0 UNRUN

Gate family derived after the merge and changeset existed, with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, and reconciled with --ran carrying a recorded exit code per family. Two prerequisites were built rather than counted green (@objectstack/formula, @objectstack/lint, @objectstack/objectql), after which check:doc-formula-expressions and check:lean-entry-closure both read exit 0.

⊘ NOT MEASURED — declared, ⛔ not counted green:

  • pnpm check:dual-build-cjs-loadsexit 3, PREREQUISITE NOT MET: reads built output for 87 packages and needs a whole-repo pnpm build. That is CI's Build Core. Nothing was measured; this is neither a pass nor a finding.
  • pnpm check:pm-dispatch-gatesexit 124, my own timeout kill 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 lint and 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

  • Clause-②: no

Why no is the honest answer for THIS diff, re-measured after the merge: the four files are the gate script (packages/spec/scripts/**, not src/**), one comment-only prose repair, the regenerated migration registry mirroring it, and the changeset. Mechanically checked — every changed line under packages/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-tells reads exit 0.

⚠️ The path leg of the enqueue gate still fires (three of the paths are under packages/spec/), so an in-seat contract review at CONTRACT_REVIEW_TIER is 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 touch needs:contract-review.

Prepared by the os-dev seat in session session_015c5G6TmpMKgnusmTpD7Ntt; merge commit d9b3fd5a3d, merge base b06b2db5c4.


Generated by Claude Code

…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
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/migrations/entries/retired-keys/18.kernel__SandboxConfig__process.timeout.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/migrations/entries/retired-keys/18.kernel__SandboxConfig__process.timeout.ts) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

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

Which tree this was computed on

This run read content/docs from c9fb9105ca4dad8363bc6739d3b8f67b076f7ea8 — the merge of head 605b7f60a4d0ce228a6e61aff748c9de91b8dfea into base e248c4d688ba46fc86af199cce7660b55b8cfb30, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:system tests tooling labels Sep 11, 2026
This was referenced Sep 11, 2026

os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status update — the blocker CHANGED on 2026-09-11T14:08Z and this PR's body never said so

domain:spec execution seat, 2026-09-12T06:5xZ. ⚠️ Posted because a reader of this PR is currently told the wrong thing about why it is parked.

What the body still says, and why it is stale

The 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

check:duration-unit-keys exits 1 with 21 offenders on this branch, because this PR is the gate that starts refusing them. ⛔ No offender was exempted, allowlisted or silenced, and no baseline was added (the gate has none, by ruling). ⚠️ Reading that red as a defect would be wrong; reading it as "nothing to do" would also be wrong.

What actually blocks it now

Ruling 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:

card file rows
#17780 kernel/plugin-lifecycle-advanced.zod.ts 3
#17781 kernel/plugin-security-advanced.zod.ts 1
#17782 system/logging.zod.ts 4
#17783 system/metrics.zod.ts 5
#17784 system/tenant.zod.ts 1
#17785 system/tracing.zod.ts 4
#17786 the three describe-only sites (no rename) 3
total 21

18 renames + 3 describe-only = the full delta this PR enumerated. #15939 is now pm:blocked with one Blocked-by: line per card in its body (the machine-greppable reverse index), parsed back to confirm all seven.

⚠️ The seven cards carry finding only — ⛔ no domain:*, no priority:*, because those have exactly one producer and it is not this seat. They owe triage's first touch before any of them can be dispatched.

⇒ This PR's disposition, stated plainly

Parked, 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 main: merge main here, confirm check:duration-unit-keys reads 0 offenders, and land it.

⚠️ Each of the 18 renames touches a published key, so per Ruling A's execution note every one of them must git grep the pinned objectui checkout at its pinned SHA before landing (AGENTS.md Post-Task Checklist step 4). That is the seven cards' obligation, not this PR's.

⚠️ This correction is mine to own: the ruling landed at 14:08Z on 2026-09-11 and I did not update this PR then. For a stretch afterwards I was also telling the maintainer that #15939 was still awaiting their decision, which was wrong for the same reason — I was asserting a card's state from memory instead of reading its thread to the last page.

domain:spec execution seat · session_01MkQhmuuJAVDjmeWNixwDDH · 2026-09-12T06:5xZ


Generated by Claude Code

…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>
@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Contract review — PR #17635, in-seat clause-② review at CONTRACT_REVIEW_TIER

Head-sha: 605b7f60a4d0ce228a6e61aff748c9de91b8dfea
Implemented-by: claude/issue-15939-duration-unit-keys-jsdoc
Reviewed-by: session_015c5G6TmpMKgnusmTpD7Ntt

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 (降档保险丝). CONTRACT_REVIEW_TIER read from origin/main:scripts/pm/dispatch-gates.mjs:10510 = claude-fable-5-1. Served tier taken from the harness-stamped transcript, ⛔ never from a self-report: 96 × claude-fable-5-1, zero other values. Discriminating control — the identical extraction over this round's os-dev transcript reads 219 × claude-opus-5, so the instrument separates tiers rather than printing whatever it is shown; dark control (a tier string invented at read time) 0.


Head reviewed: 605b7f60a4 (branch claude/issue-15939-duration-unit-keys-jsdoc, draft). Merge-base b06b2db5c4 confirmed as d9b3fd5a3d^2 and an ancestor of origin/main 6a3bcd8174 (git merge-base --is-ancestor → yes). Diff = 4 files, +261/−11 (git diff --stat b06b2db5c4 605b7f60a4). origin/main is 2 commits past the merge-base: 6 files, five *.test.ts plus scripts/pm/board-snapshot.mjs, none under packages/spec/ and none inside the gate's walk (test files are excluded by SKIP_DIRS/test-file rule, self-test case "the walk skips node_modules/, dist/, build/ and test files"). Toolchain for the runs below: typescript@6.0.3 + tsx@4.23.13 (the lockfile's pins) installed in the scratchpad; the head and control trees were detached worktrees, removed afterwards. All readings 2026-09-13, 17:20–17:47Z.

① Derived judgments

Accept-set / public-surface changes the diff produces:

  1. Gate packages/spec/scripts/check-duration-unit-keys.ts gains rule unit-in-jsdoc-not-in-describe (judge() branch if (site.durationShaped && site.jsdocUnits.length > 0), reached only after the proseUnits.length > 0 branch has returned). Direction: refuse-only — the branch adds a Finding, never returns "compliant". Verified by fixture on the head script (collectDurationKeys+judge imported directly): timeoutMs + JSDoc "milliseconds" + describe "Maximum execution time" → unit-in-jsdoc-not-in-describe; same with no describe at all → refused; durationMs (spec: three duration keys already name their unit in the key but not in .describe() — #15939 Ruling A remediation (the 3 describe-only rows of the 21-row delta) #17786 shape) → refused. This narrows what the gate accepts; it is not a widening. Right. Not a published surface: packages/spec/package.json files = dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json — no scripts/; no importer of the script anywhere (grep -rn check-duration-unit-keys over *.ts,*.mts,*.mjs,*.json,*.yml,*.md → only package.json:291, prose mentions in registry.ts:8671 / one semantic entry / workspace-enumerator.d.mts comment, CHANGELOG.md:983).
  2. export interface DurationKey +jsdoc, +jsdocUnits; Finding.rule union +1 member; --list rows gain [jsdoc: …]. Exported symbols of a non-published, un-imported script. Not a public surface. Right.
  3. Accept/reject of any published schema, any default, any runtime behaviour: unchanged. Changed lines under packages/spec/src/ that are not // comments: 0 (git diff b06b2db5c4 605b7f60a4 -- packages/spec/src | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)' | grep -vE '^[+-]\s*//' | wc -l); lit control, comment lines: 36. node scripts/pm/check-widening-tells.mjs --declaration no --diff pr.diff → exit 0, no tell (2 files judged, 2 NOT MEASURED: the script and the changeset, which have no declared surface). node scripts/pm/check-clause2-carriers.mjs --pair 17635 → exit 0, both carriers agree.
  4. Published prose channels: none moved. The rewritten comment lives in entries/retired-keys/18.kernel__SandboxConfig__process.timeout.ts (not a .zod.ts, so not in the tarball as source) and its mirror in registry.ts. docs/protocol-upgrade-guide.md renders protocol steps 10→11 … 16→17 only (grep -nE '^## ' docs/protocol-upgrade-guide.md); kernel-plugin-security-durations-unit-in-key and kernel-runtime-config-timeout-unit-in-key occur 0 times there, lit control authoring-schemas-strict-unknown-keys 1. Old phrase outside the gate's population under docs/ + content/: 0 (the 3 tree hits are the new comment quoting it and its registry mirror). content/docs/references/kernel/plugin-security-advanced.mdx:425 renders the .describe() (Process timeout in ms), not comments. Dark control qxv-tombstone-9917 → 0. Right.
  5. Registry regeneration is faithful. tsx scripts/build-migration-registry.ts --self-test --check✓ src/migrations/registry.ts is current (215 semantic, 186 retired-key, 178 retired-def); regenerating rewrote the file to the same blob d531512b72 (worktree status empty); the 18 comment lines of the entry equal the 18 registry lines after stripping the 4-space indent (Python compare → True). Not hand-edited. Right.
  6. Neighbour claims in the rewritten comment are true on this tree. plugin-security-advanced.zod.ts at head: timeoutMs: z.number().int().min(0).optional().describe('Maximum execution time in milliseconds') at :316–317, timeout: retiredKey(RUNTIME_RESOURCE_LIMITS_TIMEOUT_RETIRED) at :320, JSDoc "Execution timeout in milliseconds" above :316; entry file 18.kernel__RuntimeConfig__resourceLimits.timeout.ts exists; id kernel-runtime-config-timeout-unit-in-key at entries/semantic/18.kernel-runtime-config-timeout-unit-in-key.ts:6 and registry.ts:8849; kernel-plugin-security-durations-unit-in-key defined at entries/semantic/18.kernel-plugin-security-durations-unit-in-key.ts:6. Dark control (invented) kernel-runtime-config-timeout-unit-in-jsdoc-zq → 0. "No D2 conversion" sentence: git grep -l '#15939' 605b7f60a4 -- packages/spec/src/conversions → 0 (lit: 11 files in that dir). Right.
  7. Version axis. Added lines carry no @objectstack/spec <N> token (grep -E '^\+' pr.diff | grep -oE '@objectstack/spec [0-9]+' → empty); lit control on the tree with '…' + '…' literals joined: 5 × @objectstack/spec 17 in plugin-security-advanced.zod.ts. Pending changesets for @objectstack/spec on head: 54 minor, 55 patch, 0 major; package is 17.4.0 ⇒ this ships in 17.5.0. The 18. filename prefix is RETIRED_KEYS_BY_MAJOR[18], the protocol axis, untouched. Right.
  8. Population claim (21, remediated ahead, zero added) — reproduced with two instruments independent of the head-tree gate run. (a) Head gate script run on the pre-remediation tree 690f083f83 (parent of the first PR commit 0b9f8dbbb9): exit 1, 21 offender(s) among 211 duration-shaped numeric key(s) in 2433 source file(s), all 21 [unit-in-jsdoc-not-in-describe], at exactly the 18 rename sites + plugin-loading.zod.ts:104 durationMs, app-install.zod.ts:146 durationMs, deploy-bundle.zod.ts:129 estimatedDurationMs; the tree's own old gate on the same tree: exit 0, zero (blind, as the card said). On head: exit 0, 211 / 2482 / 6 EpochMs / 11 externalVocabulary, zero offenders. Census 211/6/11 identical before and after ⇒ no key left the census and no exemption count grew: nothing was exempted, allowlisted or baselined; the gate has no baseline mechanism (grep -in baseline → only the "No baseline, by ruling" header; no *baseline* file names this gate). (b) Tombstone entries: retired-key entry files naming #15939 on the merge-base 18 (lit control #15678 15, dark control #15939-zzq7 0); on head it reads 19 because this diff's own rewritten comment adds #15939 to the SandboxConfig entry — the instrument is clean on base, contaminated by the diff on head. Window control 165→186 entry files (+21): 18 are finding: check:duration-unit-keys reads .describe() but not JSDoc — a duration key documenting its unit only in JSDoc never enters the population, and one card already recorded a wrong reason because of it #15939's, the other 3 are 18.ui__ListView__pageName, 18.ui__ObjectKanbanProps__quickAdd, 18.ui__ObjectListView__pageName (unrelated). (c) A regex scanner independent of the gate (JSDoc directly above a z.number() key names a unit, describe does not) reads 31 on the pre tree → 11 on head; the 21 gate sites are all in the 31 and none in the 11. Right, with one caveat below.
  9. Caveat — the divergence predicate is narrower than the census it reports on. The head --list shows 2 census rows with [jsdoc: X] and [prose: -]: packages/spec/src/ai/usage.zod.ts:52 latencyMs [name: ms] [prose: -] [jsdoc: ms] null (no describe at all) and packages/spec/src/system/tenant.zod.ts:603 frequencyHours [name: hours] [prose: -] [jsdoc: hours] "Backup frequency". Both are in the 211 (via keyUnits), both are the exact shape of the three spec: three duration keys already name their unit in the key but not in .describe() — #15939 Ruling A remediation (the 3 describe-only rows of the 21-row delta) #17786 rows, and neither is refused because isDurationShaped() is a name-token list (latency, frequency absent) while the census is proseUnits>0 || durationShaped || keyUnits>0. Fixtures confirm: latencyMs shape → durationShaped=false, not refused; durationMs shape → refused. The ruling's wording ("duration-shaped numeric key") is the gate's pre-existing term of art, so the implementation follows the letter; but the changeset's "refuses the DIVERGENCE … (or there is no describe at all)" over-claims by these two rows, and the self-test's "DURATION-SHAPED only" pin uses sampleCount (a count), not the shape that actually escapes. Not a widening, not a moved outcome — a declared-narrow rule with two rows the zero does not cover. Out of this PR's landing scope by ruling A's own sequencing (remediation first, gate last); must be filed (see out-of-scope).
  10. Self-test direction pin — partial. Ablation M2 (jsdocTextOfundefined): self-test exit 1, exactly 5 red (3 positive controls, the externalVocabulary divergence, the census-carries-jsdocUnits case) ⇒ positive controls are live; an option-1 implementation would also go red because those cases assert the rule name. But mutant M1 (… && site.keyUnits.length === 0, i.e. a unit in the NAME lets the JSDoc satisfy without a describe) passes all cases (exit 0), reads 0 offenders on head, and reads 18 (not 21) on the pre tree. The two pins the body quotes ("the JSDoc does NOT satisfy the rule…", "listed, not judged…") cannot fail under M1 — they are vacuous for that back door. The real gate is correct (fixture + the body's on-disk timeoutMs control), but the guarantee is unpinned for the name-carries-unit case. Self-test prints 78 per run and "all cases pass" (58 expect( sites); the body's "156 cases" is not reproduced by one run (78 × 2 = 156 suggests a doubled count) — immaterial.

② Semver grading

patch on @objectstack/spec is the correct level and matches the changeset. The diff publishes nothing but a comment: scripts/ is outside files; the only src/ bytes changed are // lines in a non-.zod.ts entry and their mirror in registry.ts, which compiles into dist via export * from './migrations/index.js' (src/index.ts:226) with no removeComments set — so "publishes nothing from any released package" (the skip-changeset bar, AGENTS.md Post-Task step 3) is not quite met, and patch is the floor. No widening ⇒ no minor (AGENTS.md: Clause-②: yes ⇒ ≥ minor; here it is no, correctly). Ruling A prescribed Clause-②: no / patch for the whole sequence. One wording nit in the changeset: "eighteen were renames … each carrying its own ADR-0087 conversion" — on the base tree the 18 carry 8 semantic (D3) entries grouped per file and 0 D2 conversions; the repo uses "conversion" loosely (ruling A and the gate message do too), so this is cosmetic, but it is published CHANGELOG text.

③ Boundary-flag disposition

  • Clause-②: no — correct, established by ① items 1–4 with mechanical instruments and controls. The path leg still fires (3 paths under packages/spec/), which is why this review exists; the declaration leg is honest.
  • NOT MEASURED pnpm check:dual-build-cjs-loads (exit 3) — legitimate: it needs a whole-repo build; this diff touches no runtime module. Not re-run here (no workspace install in this session; declared 17:47Z). CI Build Core on 605b7f60a4 completed success 16:57:36Z — noted as the family's own instrument, not as contract evidence.
  • NOT MEASURED pnpm check:pm-dispatch-gates (exit 124) — legitimate and creditably declared by hand where the --ran reconciler would have counted it as run; the diff touches nothing under scripts/pm/. Not re-run here.
  • Repo-wide farm declared to CI (pnpm lint, artifact-roster / wide-population / path-scheduled families) — legitimate narrowing. I could not run check:generated / check:spec-changes / check:upgrade-guide locally (Cannot find module 'zod' in the bare toolchain; declared 17:47Z); the only generated artifact this diff touches, registry.ts, I regenerated byte-identically myself, and the upgrade guide provably does not render 18.* entries (① 4). Lint & Repo Gates (which runs check:duration-unit-keys at lint.yml:4542, check:spec-changes :5012, check:upgrade-guide :5015) completed success 17:06:58Z on the head SHA; 49 check runs on 605b7f60a4, all completed, none failed.
  • Open question — take a second merge of origin/main? Option A is right: the 6 advanced files are five test files plus scripts/pm/board-snapshot.mjs, none in this diff's paths and none inside the gate's walk, so the widened gate's reading on origin/main + this diff equals the reading on head.
  • objectui pre-check declared inapplicable — correct: 0 non-comment lines under src/ ⇒ nothing renamed here.
  • metrics.test.ts half dropped as obsolete — verified on head: the hunk's target phrase outside the gate population entirely and keeps its bare name → 0; bare phrase → 1 at :563, quoted as the superseded reading; REFUSES the burn-rate pin present (1); dark control 0. Nothing lost.
  • "⛔ No offender was exempted to reach that zero" — verified (① 8: census and exemption counts identical pre/post; no baseline).
  • Prose repair "one site, not two" — the sentence-by-sentence claims on the SandboxConfig entry all hold on this tree (① 6), and the second site is spec: 5 duration key(s) in system/metrics.zod.ts name their unit only in JSDoc — #15939 Ruling A remediation (5 of the 21-row delta) #17783's (above).

PASSClause-②: no and patch are correct for this diff (0 non-comment source lines, no new published symbol or key, a gate that refuses strictly more), the 21→0 population is real and reproduced on an independent control tree with no exemption or baseline, the registry is a faithful regeneration, and the rewritten prose is true on the tree; the two gaps found are real but sit outside this PR's landing scope under ruling A's sequencing and do not move any accept/reject outcome.

Out of scope (real, must be filed):

  1. Two census rows of the ruled divergence shape escape the new rulepackages/spec/src/ai/usage.zod.ts:52 latencyMs (JSDoc "Wall-clock latency in milliseconds", no .describe() at all) and packages/spec/src/system/tenant.zod.ts:603 frequencyHours (JSDoc "Backup frequency in hours", describe "Backup frequency") — because judge() gates the divergence on isDurationShaped(name) while the census admits keyUnits. Fix shape, in ruling A's order: a describe-only card for the two (no rename; the unit is already in the name), then widen the branch to (site.durationShaped || site.keyUnits.length > 0), and add a positive control whose name carries the unit (e.g. timeoutMs + JSDoc ms + describe none → refused) so the M1 back door goes red. Tighten the changeset/CHANGELOG sentence if a follow-up changeset is written.
  2. Duration-valued keys outside the census entirely (the option-1 population the ruling declined, listed for the record, not asserted as owed): packages/spec/src/kernel/plugin-versioning.zod.ts:191 estimatedMigrationTime (JSDoc hours, no describe), packages/spec/src/system/metrics.zod.ts:847 resolution (JSDoc seconds, describe "Downsampled resolution"); and four z.number() epoch timestamps whose JSDoc says milliseconds but which are not typed EpochMs (data/document.zod.ts:35 createdAt, :402 expiresAt, system/supplier-security.zod.ts:146 assessedAt, :151 validUntil) — a [#14478 stack 1/6] declare the two exemption classes ON THE SCHEMA — a shared EpochMs for the 6 epoch instants and a .meta({ externalVocabulary }) marker on the 13 external-standard keys, honoured by check:duration-unit-keys and printed by the docs generator #15676 ruling-B shape, not this card's.
  3. JSDoc-vs-describe unit contradiction is not a class (timeoutMs + JSDoc "seconds" + describe "milliseconds" → not refused). Consistent with batch Add comprehensive test coverage for ObjectStack spec protocols - 100% coverage achieved #65 (JSDoc is not governed prose); noted only.
  4. tests label on the PR is stale — the diff no longer touches a test file. Label write is the seat's.

epic PM for #15939 · session_015c5G6TmpMKgnusmTpD7Ntt · 2026-09-13T17:55Z


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 13, 2026 17:52
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 4844840 Sep 13, 2026
54 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-15939-duration-unit-keys-jsdoc branch September 13, 2026 18:22
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants