feat(spec)!: the three kernel/plugin-lifecycle-advanced.zod.ts duration keys carry their unit in the key name - #17986
Conversation
📓 Docs Drift CheckThis PR changes 2 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 141 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ef2f3d1eec602eda5ee2ac7e37029a78ac1c5eac && git checkout ef2f3d1eec602eda5ee2ac7e37029a78ac1c5eac
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 65767d2340dabce16953908699082dd97032da3b db55ea6dde75d3e9f888d66cbad84c78c9006f0c && git checkout -B drift-repro 65767d2340dabce16953908699082dd97032da3b && git merge --no-ff db55ea6dde75d3e9f888d66cbad84c78c9006f0c
node scripts/docs-audit/affected-docs.mjs --json 65767d2340dabce16953908699082dd97032da3b
|
8159cfd to
7c68204
Compare
… the key name `PluginHealthCheck.interval` / `.timeout` and `HotReloadConfig.debounceDelay` each named milliseconds in a source JSDoc only. `.describe()` is what the reference pages publish and the JSDoc above a key is not, so the reader who most needs the unit was the only one who never saw it — and `interval`'s describe carried a "(default: 30s)" parenthetical naming SECONDS for a value the schema bounds and defaults in milliseconds. Renamed to `intervalMs`, `timeoutMs` and `debounceDelayMs`, with a `retiredKey()` tombstone on each old spelling (neither object is `.strict()`, so a bare deletion would silently strip the value onto a `setInterval` period, a race deadline and a `setTimeout` delay), an ADR-0087 D3 semantic entry and three `RETIRED_KEYS_BY_MAJOR[18]` rows. `@objectstack/core` moves with it: both lifecycle classes read the suffixed keys and refuse an old spelling at registration with the rename. Executes director-seat ruling A on #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file remediation of the #14478 rule. Co-Authored-By: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt
7c68204 to
db55ea6
Compare
Contract review
Reviewed in an isolated worktree at the head sha (merge-base with ① Derived judgments
② Semver level
③ Boundary flags
Readings I re-measured
Implemented-by: branch claude/issue-17780-plugin-lifecycle-duration-units VERDICT: PASS Generated by Claude Code |
Carriers cleared — provenance
Full acceptance, including the disposition of all four review escalations and one dark control of my own that I had to throw out and redo, is on the card: #17780 comment ⏳ Not enqueued yet. The label write above re-triggered a check run, so ⛔ No approval is given or implied here. This is a governed surface: the clause-② review clears the queue gate, it is not a review approval, and this seat neither approves nor merges. Generated by Claude Code |
…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 <[email protected]>
…a copyable template (objectstack-ai#18138) Fixes objectstack-ai#18042 The contract-review record is the one machine-read artefact this skill described only in prose. `references/contract-review.md` names its shape (:28), its `Served-tier:` first line (:29) and the machine-read authorship pair (:35, :37) — and nothing in the tree could be copied. Four in-seat records in one session composed the pair from memory and put a word in front of the value; C4 refused all four as HALF WRITTEN, and two reached `main`, where C6 then reads「no review of record on that head」. The standing remedy this family prescribes everywhere else — COPY the template's line rather than composing one — was unfollowable for exactly this artefact, because nothing existed to copy. ## The measured instance (⛔ read only; not edited, not re-posted) Comment 5652813288 on PR objectstack-ai#17986 carries the pair as: ```text Implemented-by: branch claude/issue-17780-plugin-lifecycle-duration-units Reviewed-by: session_015c5G6TmpMKgnusmTpD7Ntt ``` The leading word is the entire defect. `readImplementerToken` strips DECORATION (spaces, `**`, backticks) and never a word, so the token has to be the first thing after the colon — 「值紧跟冒号,前置词即不可读」. Both directions are now pinned: the specimen verbatim reads `malformed`, and the same record with that one word removed reads `pair`. ## What landed The checker that ENFORCES the shape now EMITS it: ```bash node scripts/pm/check-clause2-carriers.mjs --template # exit 0, no board is read ``` It prints the record fenced between two markers, with every value directly after its colon, followed by a per-key legend. The record block (its last line, the verdict line in caps, is omitted HERE on purpose — a body carrying it would be readable as a verdict comment by any reader handed this body, and this PR is not a review of record): ```text ## Contract review Served-tier: `CONTRACT_REVIEW_TIER` Head-sha: `0000000000000000000000000000000000000000` ### ① Derived judgments ### ② Semver level ### ③ Boundary flags Implemented-by: `claude/issue-NNNN-slug` Reviewed-by: `session_SEATSESSIONID` ``` Three properties the placeholders are chosen for, each pinned by a self-test case: - **They parse.** An unedited paste still reads as a pair, so what an unreplaced placeholder changes is WHOSE record it is, never whether it reads at all. - **The head placeholder is git's null oid**, so the shape is a recognised hex span (the template is provably round-trippable) while prefixing NO real head — a record pasted unedited is refused by C6, never silently adopted. Fail-closed in the one direction that matters. - **No angle brackets anywhere.** The body sanitizer eats tag-shaped fragments, backticked ones included; a placeholder spelled that way would be eaten out of the very comment a seat pastes it into. This is the same fact `CLAUSE2_CORRECTION_KEY_LINE` already states for the correction carrier, and it is why the card's suggested `issue-(n)-(slug)` spelling is NOT what shipped. Two shape corrections the template carries by construction, both measured on the specimen: 1. **The head sha gets a code span of its OWN.** `H51_SHA_SPAN` matches a span that is hex and nothing else, so the corpus spelling — the whole `Head-sha: …` pair inside ONE span, which is what comment 5652813288 writes — matches nothing, and that record names no head at all. A self-test CONTROL pins the difference. 2. **The verdict word is in caps.** `VERDICT_MARKER` is case-sensitive, so the capitalised spelling is recognised by the marker alone, without depending on the head match. `c4VerdictSelfReview`'s own HALF WRITTEN refusal now names the flag, which closes the loop: the row a seat lands on when it gets the pair wrong tells it what to copy. ## The route taken, and why The seat's suggested route, and measurement agreed with it: the checker emits the template rather than the doc carrying a literal block. `contract-review.md` is 60/60 with zero headroom, so a literal block would have had to be funded by deleting five or six rule lines. ⛔ Re-wrap is not currency and ⛔ no ceiling was raised. One detail of the suggested route was falsified by measurement and is reported rather than followed: the pointer could not land on :35, which is already at exactly **120 bytes** — the cap — and :29 held only 10 bytes of headroom, less than any pointer spelling costs. ## Line budget — net 0, paid by density INSIDE the file ```text :29 before 110 B - 同形含首行 `Served-tier:`:值写常量名 `CONTRACT_REVIEW_TIER`,可前置 N/N;无此行不成裁决。 :29 after 119 B - 同形含首行 `Served-tier:`:值写常量名 `CONTRACT_REVIEW_TIER`;无此行不成裁决,模板见 `--template`。 ``` Paid by dropping 「,可前置 N/N」 (16 B) to fund 「,模板见 `--template`」 (25 B): 110 − 16 + 25 = 119 B, under the 120-byte cap, and the file stays at 60 lines with no line added or removed. **Where the dropped clause survives — named, as the ruling requires.** The stamp control moves into the printed legend, where the author actually copies from: `· Served-tier … an at-tier/total stamp control may precede it — 75/75, then the constant`, pinned by a self-test case so it cannot silently vanish. Its ENFORCEMENT never lived in the doc at all: `STAMP_CONTROL` and `servedStampsHold` carry it, unchanged by this PR. C7's rule string, which mirrors :29 verbatim, is updated in the same edit so the quote cannot go stale. ## Acceptance greps, both directions ```text node scripts/pm/check-clause2-carriers.mjs --template :: exit 0 prints Implemented-by: `claude/issue-NNNN-slug` (value directly after the colon) pointer phrase 「模板见 `--template`」 base 0 -> head 1 lit control 「值紧跟冒号」 head 1 (still hits) contract-review.md line count base 60 -> head 60 LC_ALL=C awk 'length($0)>120' prints nothing git diff --stat origin/main -- .claude/skills/pm-dispatch/SKILL.md empty check-skill-line-ratchet 60 lines (ceiling 60; headroom 0) :: exit 0 ``` ## Reverse verification (one-off, nothing left in the tree) Committed first, then the template's `Implemented-by:` value was mutated to carry the measured leading word, on disk and proven there (clean anchor 1 → 0, mutated anchor 0 → 1; blob `cb3c41f79` → `50c4ed175`): ```text node scripts/pm/check-clause2-carriers.mjs --self-test :: exit 1, 5 case(s) ✗ ✗ ⭐ the PRINTED template round-trips through the reader that judges the real thing ✗ …reading the BRANCH placeholder as the implementer, never the prose around it ``` Restored with `git checkout HEAD -- ` (⛔ not the bare form, which restores from the polluted index): `git diff HEAD --stat` empty, blob back to `cb3c41f79`, byte-identical to HEAD. ## Gates — 42 derived, 42 run, 0 NOT-MEASURED, 0 UNRUN Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths), every command run in the foreground with `$?` captured before any pipe, then reconciled with `--ran` carrying each exit code: ```text ✓ dispatch-gates --ran: 42 derived famil(ies) accounted for — 42 run, 0 NOT-MEASURED (a DERIVED zero — all 42 recorded an exit code and none of them is 3). ``` All 42 exit 0, including `check:pm-clause2-carriers` (628 self-test cases, 23 of them this card's), `check:pm-skill-ratchet`, `check:pm-skill-id-lint`, `check:pm-governed-merges`, `check:nul-bytes` and `check:pm-dispatch-gates`. Two ran outside the derivation and also pass: `check:pm-governed-prose` and `node scripts/check-skills-token-ratchet.mjs`. `check:doc-formula-expressions` first exited **3 — PREREQUISITE NOT MET** (a workspace package was unbuilt). That is not a verdict: the two packages were built under `scripts/pm/os-verify-lock.sh` and it was re-run to exit 0. ## Changeset None. `.claude/**` and `scripts/pm/**` publish nothing — no package `files[]` ships either path — so this carries the `skip-changeset` label instead. ## Out of scope — reported to the PM rather than filed The write budget for this dispatch is `git push` + one `POST /pulls` + the label + one report comment, so these are handed to the PM to file rather than filed here: - **(a) A record written in the corpus's own head-sha spelling names no head.** Comment 5652813288 writes `Head-sha: …` and the sha INSIDE one code span; `H51_SHA_SPAN` needs a span that is hex and nothing else, so `contractReviewHeadMatch` answers `null` and C6 reads no review of record on that head even where a correct record exists. Probe: the CONTROL case in this PR's own battery. `contract-review.md` :28 says 「所审 head sha 码段」 without saying the span holds the sha ALONE, which is the authoring trap behind it. The template closes it for every record written from here on; it does not touch the ones already on the board. - **noted, not filed:** comment 5652813288 also carries no `Served-tier:` line at all, so under C7 it would not stand as a verdict today. Successor: whoever next touches the record shape — this PR does not, and ⛔ a merged record is not edited. ## 维护者速读(草稿) **改了什么** — 契约复核记录第一次有了可照抄的模板:执行形状的那个检查器现在自己打印它 (`--template`),值一律紧跟冒号;`contract-review.md` :29 加一句指针,行数不变。 **为什么改** — 一个会话里四份席内记录都把 `Implemented-by:` 写成「前置一个词」,四份全被 C4 判 HALF WRITTEN,其中两份已经进了 `main`,那两条 head 上等于「没有复核记录」。规则一直写在散文里, 四个连续的读者都没读出来;本仓其他机读字段的标准解法是「照抄模板」,唯独这一份没有模板可抄。 **风险与代价(含回滚)** — 只动两个文件,都不发布,不影响任何运行时;新增的是一个只打印、不读板子的 flag,和 23 条自测。代价是规则行里 「可前置 N/N」 换成了模板指针 —— 该条的强制逻辑在检查器里没动, 说明移进了模板的图例并被自测钉住。回滚 = revert 本 PR,没有迁移、没有数据、没有下游依赖。 **席位意见** — **你要做的** — 确认这条取舍:规则层用 16 字节的「可前置 N/N」换 25 字节的模板指针,是否接受。 若接受,本 PR 无其他待决项;另有一条超范围发现(记录里 head sha 的码段拼法)已写在上一节,请代立卡。 Clause-②: no — the diff adds no key, arm, export or registration on any declared surface; `check-widening-tells --declaration no` judged 2 changed file(s), 0 against a declared surface. _Generated by [Claude Code](https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8)_ --- _Generated by [Claude Code](https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8)_ --------- Co-authored-by: Claude <[email protected]>
…e refused spelling (objectstack-ai#18270) Fixes objectstack-ai#18141 `references/contract-review.md` :28 defined the review of record's shape as 「所审 head sha 码段」 and never said the span holds the sha ALONE. A seat reading it in good faith writes the key and the sha into ONE span; `H51_SHA_SPAN` matches a code span that is hex and nothing else, so such a record names no head and C6 reads 「no review of record on that head」 where a complete review exists. Taken by the file's own 「先删容许出错的构造,再让正确形态成唯一拼写」 order: the prose names the one correct spelling, and the reader keeps refusing the other while NAMING it. The accept set is unchanged — a second accepted spelling would be the trap's twin. ## What changed - `.claude/skills/pm-dispatch/references/contract-review.md` :28 — 「所审 head sha 码段」 becomes 「所审 head sha 独占码段」, and the line sheds 「判词」 to pay for it. The file is 60 lines at its 60-line ceiling before and after; the touched line is 118 bytes (119 before). `check:pm-skill-ratchet`: "contract-review.md is 60 lines (ceiling 60; headroom 0)". - `scripts/pm/check-clause2-carriers.mjs` — - `HEAD_KEY_IN_SPAN` + `headSpanHoldsKey(pair)`: a DIAGNOSIS, read only AFTER the locator has already answered `absent`. It chooses no comment, admits none, and returns nothing for a comment the locator can already read. The newest-of idiom is `latestMarkedComment`, the same one the locator resolves with — never a second one. - C6's absent row is now two sentences: the empty case keeps 「a cleared gate with nothing behind it」, and a pair whose heading comment wrote the head inside the key's span gets a row naming the comment, quoting the span the seat actually wrote, and prescribing the fix (key outside the span, sha in a span of its OWN, `--template` prints the whole record). - The shared shape sentence now names the one spelling on both branches. - The docblock quotation of the rule line was updated in the same edit, so the file does not quote a sentence this PR replaced. - 19 self-test cases in their own battery (658 before, 677 after; roster floor 23, now 24). ## The measurements this PR was dispatched to take **P1 — falsified in its live half; the mechanism half stands.** The dispatch expected C6 to read 「no review of record」 for comment 5652813288 (PR objectstack-ai#17986) today. Measured on the exact bytes (GET, not edited) against this tree: ``` contractReviewHeadMatch(body, head) = "db55ea6dd" hex-only spans in the body: 884e834, fc28c1d, ..., db55ea6, 8cdd696, 53ded82bf7a494f54e344e19099dbf00854b8694 spans that prefix the head: db55ea6 key-in-span line ALONE: null locateReviewOfRecord: { state: "found", id: 5652813288, sha: "db55ea6dd" } ``` Its `Head-sha:` span names nothing — the mechanism the card describes is real — but the record is still FOUND, because its own prose quotes the head in a bare span of its own ("Cross-file staleness, searched at `db55ea6dd`"). So the sha C6 reports for that record is read off the prose and not off the line the seat wrote it on. Replayed end to end through the CLI (`--pair 17986 --pair-json`, verbatim bytes): no C6 row, the C6-RECORD note naming `db55ea6dd`; exit 4 comes from C4 (`Implemented-by: branch …`, half written) and C7 (no `Served-tier:` line), both facts about that record that predate this PR. The defect is the SPELLING, not that comment — the same record trimmed to the spelling alone reads the refusal (below). **P2 — holds.** `contractReviewRecordLines` prints `Head-sha: ` followed by the sha in a span of its own, and the `--template` note already says "7 to 40 hex in a span of ITS OWN; a span holding the key as well is not a sha". The prose now agrees with it, and the self-test derives its refused fixture by COLLAPSING the template's own line rather than retyping the key — a template that renamed the key reds this battery instead of drifting past it. **P3 — the distinction was absent; it is the sentence that was added.** Before this PR both cases printed 「a cleared gate with nothing behind it, indistinguishable from never reviewing」. Pinned three ways now: a bare-sha span record reads `found`; a key-in-span record is `absent` AND earns the refusal naming the spelling; a comment with no heading is the plain absence with no spelling sentence. **P4 — holds, pinned.** `locateReviewOfRecord` chooses the same comment it chose before: a refused spelling is never chosen over a correct record and never chosen at all (pinned in both arrival orders, and the pair with a correct record earns no C6 row and no spelling sentence). ## Reverse verification - Ablation (fix committed first, mutation proved on disk, restored byte-identical): replacing `const keyed = headSpanHoldsKey(pair);` with `const keyed = null;` turns **5 of 677** self-test cases red (`ABLATED EXIT=1`). Mutated blob `a3d4174b` vs HEAD blob `af1a124a`; after restore the blob is `af1a124a` again and `git diff HEAD` is empty. An earlier run of the same ablation moved only 3 cases — two pins were reading a comment count and a thread name, which survive the ablation; both were retied to the sentence the branch composes and are in the 5. - Offline replay of the refused spelling (`--pair 17986 --pair-json`, the record trimmed to comment 5652813288's spelling): exit 4, one row, C6, reading 「⚠️ The SPELLING is why, and this pair is NOT the empty case: the PR thread's comment 5652813288 … writes this head INSIDE one code span, as `Head-sha: db55ea6`」 with the remedy naming the span of its own and `--template`. - Live control, a pair carrying a correct record: `--pair 18256` still exits 0 with the C6-RECORD note on comment 5674761187 (head `c96b507db288c20bf270c66c6137dc6fa7e79576`). ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no paths, reconciled with `--ran`: ``` Run reconciliation — 42 derived, 42 run, 0 NOT-MEASURED, 0 UNRUN. EXIT CODES — all 42 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them. ``` `pnpm --filter @objectstack/lint run check:doc-formula-expressions` first exited **3** (PREREQUISITE NOT MET — `@objectstack/formula` and `@objectstack/lint` unbuilt). Built under the shared verify lock and re-run: exit 0. `pnpm check:pm-dispatch-gates` ran to completion (exit 0), not cap-killed. `pnpm check:pm-clause2-carriers`: 677 cases pass. Lint, as a proved narrowing rather than a repo sweep: `pnpm lint` is `eslint . --no-inline-config`; of this diff's two paths only `scripts/pm/check-clause2-carriers.mjs` is inside eslint's own population — the `.md` comes back "File ignored because no matching configuration was supplied". `--format json` returns 2 entries, 1 linted, 0 errors, 0 warnings. `eslint.config.mjs` states of itself that it "never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file", so this diff cannot move the verdict of a file it does not contain. No package typecheck is owed: the diff is one `.mjs` under `scripts/` and one `.md`. Union head: the readings above were taken at `c3533346`. ## Deviation from the dispatch The dispatch said the clause is "paid by density" at 60/60. `.claude/agents/os-dev.md` states that the only legal currency for the line ratchet is DELETED CONTENT and that a re-wrap must never buy a line for new content, so folding two clauses into one line to free a 61st was not available, and no clause in this file is redundant enough to delete. The clause therefore lands INSIDE :28: the file never grows, the ceiling row is untouched, and the payment is 「判词」, whose fact is carried by the `PASS/FAIL` token it stood behind. Flagged here rather than chosen silently. ## Acceptance notes - noted, not filed: `contractReviewHeadMatch` scans the WHOLE comment, so the head it reports can come from a span in the prose rather than from the record's own `Head-sha:` line — which is how comment 5652813288 reads `found` today despite the refused spelling. H51's declared shape is "the head sha written as a code span somewhere in the comment", so this is declared behaviour, not a contract violation; it does mean the trap is survivable for some records and not others. Successor: whoever next touches H51's recognition shape. - noted, not filed: that same record carries `Implemented-by: branch claude/…` (C4 half-written) and no `Served-tier:` line (C7). Both are already recorded on the card; a merged record is not edited. ## 维护者速读(草稿) **改了什么** —— 契约复核记录的 head sha 从此必须单独占一个码段:`contract-review.md` :28 的措辞改成 「所审 head sha 独占码段」,并由同一行删去「判词」买单(文件仍是 60 行,不动天花板)。机读一侧, `check-clause2-carriers.mjs` 在判定「本 head 无复核记录」之后,额外说出**为什么**:如果有人把 `Head-sha:` 和 sha 写进同一个码段,这条 C6 行会点名那条评论、引用他写的码段,并给出一次就能改对的修法。 **为什么改** —— 旧措辞只说「head sha 码段」,照字面写就会落进读不出的拼写:一份完整的复核记录,机器读 起来和「根本没人复核」完全一样。先让正确形态成为唯一拼写,再让拒收带上理由。 **风险与代价(含回滚)** —— 受理集合没有变宽:被拒的拼写仍然被拒,定位器选哪条评论一字未动(两个到达 顺序都已钉住)。新增的只是一句诊断文案与 19 条自测。回滚 = revert 本 PR,无数据、无产物、无发布面。 **席位意见** —— **你要做的** —— 这是 `references/` 层受管面,按 Prime Directive objectstack-ai#14 走席内契约档复核 → ready → 入队, 不需要维护者逐条拍板;若对「删掉『判词』来买行」这笔密度支付有异议,请在此处说一句,我按你的说法改。 --- _Generated by [Claude Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_ --------- Co-authored-by: Claude <[email protected]>
Fixes #17780
Clause-②: yes
Executes director-seat ruling A on #15939 (2026-09-11, maintainer 「同意」, decision batch #115) for
packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts— 3 rows of the 21-row delta, following the #15678 / #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 17986reads 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 (
interval1,timeout1,debounceDelay1; lit controlz.number12, dark control 0), and each JSDoc was read rather than inferred from the default:.describe(), beforePluginHealthCheck.intervalintervalMsPluginHealthCheck.timeouttimeoutMsHotReloadConfig.debounceDelaydebounceDelayMsAll three are milliseconds. The
check:duration-unit-keyscensus reads all three[name: -] [prose: -]onorigin/main— no unit in the name and none the gate recognises in the published prose.intervalis the sharpest: its one unit-shaped token was the parenthetical(default: 30s), naming SECONDS for a value the schema bounds atmin(1000)and defaults to30000milliseconds.Why these spellings
Counted on this tree, not assumed from the card: 100 key-position
*Msdeclarations acrosspackages/spec/src, of whichtimeoutMsis 29 andintervalMsis 3 — both renames land on names the surface already uses.The analogous question to the sibling round's
Ttl-versus-TTLwas asked fordebounceDelayand 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; nodebounceMsvariant anywhere), while the Delay-plus-Mspairing 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. NeitherPluginHealthCheckSchemanorHotReloadConfigSchemais.strict(), so a bare deletion would be a silent strip — and here the stripped value lands on asetIntervalperiod, a race deadline and asetTimeoutdelay.kernel-health-check-and-hot-reload-durations-unit-in-keyand threeRETIRED_KEYS_BY_MAJOR[18]rows — both, per the PM's settlement on 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. No D2 conversion: neither def is an authorable surface (both are library parameters a host passes toPluginHealthMonitor/HotReloadManagerin TypeScript), the same dispositionplugin-auto-restart-never-reinitialisedandhot-reload-watch-placeholder-retiredrecorded for keys on these two defs.registry.tsis generated —gen:migration-registry, never hand-merged.@objectstack/coremoves 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-0112VALIDATION_ERROR/ 400 naming the rename instead ofundefinedwhere 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).authorable-surface/kernel.json,authorable-defaults/kernel.json,content/docs/references/kernel/plugin-lifecycle-advanced.mdx.pnpm --filter @objectstack/spec check:generatedreports 15 of 15 up to date.content/docs/protocol/kernel/lifecycle.mdxupdated where it names the keys (+8 / −6).The authorable-surface ratchet MOVES here, and that is correct
authorable-surface/kernel.jsonrecords 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 matchingauthorable-defaultsmoves. This is the opposite reading from sibling PR #17954, whose key was nested (performance.schemaCacheTTL) and correctly moved nothing: different readings, same rule, both right. Regenerated bygen: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 themin(1000)bound, a pin that can no longer fail. They now assert thetoo_smallissue code on the suffixed keys plus an accepting control at the boundary. The default and fixture pins were re-pointed at the new spellings withnot.toHavePropertyon 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 atregisterPluginbefore the plugin is stored, with an accepting control beside each.Consumer sweep —
@objectstack/coreis the only readerpackages/specandpackages/corePluginHealthCheckParsed/HotReloadConfigParsedPluginHealthCheck·HotReloadConfig·PluginHealthMonitor·HotReloadManager//comment in apackages/restpin test, and two CHANGELOG lines. Zero code readers.Lit controls on the same corpus:
@objectstack/spec14580,PluginContext973,ObjectKernel588. Dark controlzzqqxx0.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 ofplugin-lifecycle-advanced.zod.tsoccur 0 times, anddebounceDelayoccurs 0 times. Lit controls on the same corpus:objectstack10171,@objectstack/spec3479,timeout832,interval156. Dark controlzzqqxx0. Console Pin Gate is unaffected.Verification
pnpm --filter @objectstack/spec buildVERDICT command-exit 0pnpm --filter @objectstack/spec check:generatedpnpm --filter '@objectstack/core^...' buildVERDICT command-exit 0pnpm --filter @objectstack/core buildVERDICT command-exit 0pnpm --filter @objectstack/core testVERDICT command-exit 0— 51 files, 1316 testspnpm --filter @objectstack/spec testVERDICT command-exit 0— 475 files, 13517 testspnpm --filter @objectstack/spec --filter @objectstack/core typecheckVERDICT command-exit 0dispatch-gates --commands→ all 110, reconciled with--ran … :: exit NThe six exit-3s are
PREREQUISITE NOT METon 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-examplesinitially refused with exit 1 on an unbuilt@objectstack/client-react— the refusal shape that a--ranreconciler counts as run; it was measured properly after building that closure and reads exit 0 (258 prose examples across 3 surfaces).Acceptance notes
HotReloadConfig.shutdownTimeoutis deliberately not renamed with these three. Its JSDoc reads "Graceful shutdown timeout" and names no unit anywhere — it is the unit-nowhere shape the spec: duration-shaped number keys carry their unit in describe prose only — twottlkeys with different units in one block, baretimeoutkeys, unit-less tenant timeouts #14478 gate leaves outside its verdict by measurement, and it is not one of the 21 rows PR feat(spec): refuse a duration key whose JSDoc names a unit its describe does not #17635 enumerates. Noted, not filed: the gate's own--listcensus already keeps it visible, and its header records that closing that class is a describe-by-describe decision rather than a defect.check-widening-tellsraising T1 on theretiredKey()tombstone lines is the known inverted false positive ([finding] check-widening-tells fires T1 on a retiredKey() tombstone line, so every ADR-0087 key retirement reads as a clause-2 widening for the one reason the accept set shrank #17955) — the lines it fires on make the accept set strictly narrower. The diff is not reshaped and the checker is not weakened.timeoutMsstring 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.mdxandautomation/hook-bodies.mdx— job scheduling, webhook retry backoff and the ADR-0102 sandbox CPU budget, none of themPluginHealthCheckorHotReloadConfig(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 threecontent/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