Skip to content

feat(spec)!: the three kernel/plugin-lifecycle-advanced.zod.ts duration keys carry their unit in the key name - #17986

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-17780-plugin-lifecycle-duration-units
Sep 13, 2026
Merged

zhuangjianguo merged 1 commit into
mainfrom
claude/issue-17780-plugin-lifecycle-duration-units

Conversation

@zhuangjianguo

@zhuangjianguo zhuangjianguo commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

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 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 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 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 generatedgen: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 #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

⛔ Draft on purpose: the in-seat clause-② review at tier is owed first, and landing is the PM's step.


Generated by Claude Code

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 13, 2026
@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/spec, touching 18 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/spec/authorable-defaults/kernel.json, packages/spec/authorable-surface/kernel.json, packages/spec/src/migrations/entries/retired-keys/18.kernel__HotReloadConfig__debounceDelay.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via timeoutMs (literal, a string literal in RETIRED_HEALTH_CHECK_KEYS))
  • content/docs/automation/hook-bodies.mdx (via timeoutMs (literal, a string literal in RETIRED_HEALTH_CHECK_KEYS))
  • content/docs/automation/jobs.mdx (via intervalMs (literal, a string literal in RETIRED_HEALTH_CHECK_KEYS), timeoutMs (literal, a string literal in RETIRED_HEALTH_CHECK_KEYS))
  • content/docs/automation/webhooks.mdx (via intervalMs (literal, a string literal in RETIRED_HEALTH_CHECK_KEYS), timeoutMs (literal, a string literal in RETIRED_HEALTH_CHECK_KEYS))
  • content/docs/deployment/environment-variables.mdx (via timeoutMs (literal, a string literal in RETIRED_HEALTH_CHECK_KEYS))
  • content/docs/protocol/kernel/lifecycle.mdx (via PluginHealthCheckSchema (symbol, a top-level const), PluginHealthMonitor (symbol, a top-level class), registerPlugin (symbol, a method of class PluginHealthMonitor), startMonitoring (symbol, a method of class PluginHealthMonitor), intervalMs (literal, a string literal in RETIRED_HEALTH_CHECK_KEYS), timeoutMs (literal, a string literal in RETIRED_HEALTH_CHECK_KEYS))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via timeoutMs (literal, a string literal in RETIRED_HEALTH_CHECK_KEYS))
  • content/docs/releases/v17/17-0.mdx (via timeoutMs (literal, a string literal in RETIRED_HEALTH_CHECK_KEYS))
  • content/docs/releases/v17/17-4.mdx (via timeoutMs (literal, a string literal in RETIRED_HEALTH_CHECK_KEYS))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/spec/authorable-defaults/kernel.json, packages/spec/authorable-surface/kernel.json, packages/spec/src/migrations/entries/retired-keys/18.kernel__HotReloadConfig__debounceDelay.ts, …) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 141 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 65767d2340dabce16953908699082dd97032da3bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from ef2f3d1eec602eda5ee2ac7e37029a78ac1c5eac — the merge of head db55ea6dde75d3e9f888d66cbad84c78c9006f0c into base 65767d2340dabce16953908699082dd97032da3b, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 65767d2340dabce16953908699082dd97032da3b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… 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

Copy link
Copy Markdown
Collaborator Author

Contract review

Head-sha: db55ea6dde75d3e9f888d66cbad84c78c9006f0c

Reviewed in an isolated worktree at the head sha (merge-base with origin/main = 884e8347d; origin/main at review time = fc28c1d38, which already carries sibling PR #17954). No file in the worktree was edited; git status --porcelain empty at 2026-09-13T10:48:38Z. Every reading below names its ref and UTC time.

① Derived judgments

  • PluginHealthCheck.intervalintervalMs — RIGHT. JSDoc at 884e8347d reads "Health check interval in milliseconds"; min(1000) / default(30000) corroborate ms; the old describe's only unit token was "(default: 30s)", naming seconds for a ms value. intervalMs is an attested key spelling on this tree (3 key-position declarations in packages/spec/src/**/*.zod.ts at 884e8347d).
  • PluginHealthCheck.timeouttimeoutMs — RIGHT. JSDoc "Timeout for health check in milliseconds"; min(100) / default(5000); timeoutMs is the family's most attested spelling (29 key-position declarations in *.zod.ts).
  • HotReloadConfig.debounceDelaydebounceDelayMs — RIGHT on the rule (append the unit token to the existing key, the [#14478 stack 3/6] kernel/: the 14 remaining duration keys carry their unit in the key name — ADR-0087 conversions with readers (runtime-emitted measurements included) #15678/[#14478 stack 4/6] system/: the 15 remaining duration keys carry their unit in the key name — ADR-0087 conversions with readers; metrics.zod.ts size needs an honest name, not the mechanical one #15679 shape; *DelayMs is attested 26 times in key position in packages/spec/src at 884e8347d). One prose imprecision, not a defect: the body, the changeset and both migration entries say "no debounceMs variant anywhere" — measured at 884e8347d, debounceMs occurs 6 times in the repo, including packages/spec/authorable-surface.base.json:4673 kernel/PluginHotReload:debounceMs (a key of a def retired in 18, registry.ts 'kernel/PluginHotReload' row present) and a private field in driver-sqlite-wasm. It is absent from packages/spec/src, so the choice stands; the sentence should have said so rather than "anywhere".
  • Tombstones actually refuse — RIGHT. retiredKey() is z.never({ error }).optional() (packages/spec/src/shared/retired-key.ts), so absence passes and any value raises the prescription. Neither PluginHealthCheckSchema nor HotReloadConfigSchema is strict: 0 code-line .strict( / strictObject( on the file at head (7 mentions, all in comment lines; lit control permission.zod.ts 4 code-line strictObject(), and both are plain lazySchema(() => z.object({...})). On a non-strict object a bare deletion would silently strip the key onto a setInterval period, a race deadline and a setTimeout delay, so the tombstone is required here, not optional. The spec pin asserts the issue code is not unrecognized_keys and the message carries the rename.
  • ADR-0087 kit — RIGHT. Semantic entry kernel-health-check-and-hot-reload-durations-unit-in-key plus three RETIRED_KEYS_BY_MAJOR[18] rows (kernel/PluginHealthCheck:interval, :timeout, kernel/HotReloadConfig:debounceDelay), same major and same wording form ("renamed … in @objectstack/spec 17") as the landed PluginHealthReport.metrics.responseTime row on this file. registry.ts is generated: tsx scripts/build-migration-registry.ts --self-test --check → "current (208 semantic, 171 retired-key, 178 retired-def)", exit 0 at 2026-09-13T10:43:00Z. Naming of the entry files matches the sibling files in the same directories.
  • D3-not-D2 — RIGHT. packages/spec/src/conversions/registry.ts at head names neither def (0 for PluginHealthCheck|HotReloadConfig; lit control datasource.config.timeout (turso) 1; dark 0), and no schema outside the file embeds PluginHealthCheckSchema / HotReloadConfigSchema (only comments). That is the disposition every prior retirement on these two defs took (autoRestart destroys the plugin and never re-initialises it, then reports it recovering — and, once successThreshold binds, healthy #12032, [finding] HotReloadConfig.stateStrategy 'disk'/'distributed' are silent memory fallbacks, and distributedConfig has zero readers — declared≠enforced inside the KEPT host-driven library #12340, [finding] HotReloadManager.startWatching watches nothing and logs "File watching started" at info; watchPatterns has no reader and watchHandles is never populated #12428, [#14478 stack 3/6] kernel/: the 14 remaining duration keys carry their unit in the key name — ADR-0087 conversions with readers (runtime-emitted measurements included) #15678). ADR-0087 D2 converts at the loader over a normalized stack; a def no document carries has no seam, so a conversion would be a transform that never runs. The upgrade guide and spec-changes.json are current at head (build-upgrade-guide.ts --check, build-spec-changes.ts --check both exit 0 at 2026-09-13T10:46:56Z).
  • packages/core runtime — RIGHT. health-monitor.ts reads config.intervalMs (setInterval period, log field) and config.timeoutMs (race deadline, message); hot-reload.ts reads config.debounceDelayMs. Bare readers left in packages/core/src: 0 (lit control suffixed reads 18; dark 0). Refusal is reachable: assertNoRetiredKeys is the first statement of PluginHealthMonitor.registerPlugin and runs before any set; in HotReloadManager.registerPlugin it runs before the enabled early-return and before reloadConfigs.set. Shape is ADR-0112: code = 'VALIDATION_ERROR', status = 400 on the thrown Error, the same envelope the sibling rows use; VALIDATION_ERROR is in error-code-ledger.zod.ts. Both core tests pin refusal-before-storage plus an accepting control.
  • Ratchet baselines MOVE, and that is correct — VERIFIED from the generator, not the claim. packages/spec/scripts/build-schemas.ts builds the ledger as for (const [defKey, schema] of generatedSchemas) for (const [name, prop] of Object.entries(schema.properties)) currentKeys.set(defKey + ':' + name, isRetired(prop)) — top-level properties of each generated def, tombstones marked [RETIRED], isRetired = the { not: {} } a z.never() emits. A key inside an inline sub-object is not a row. Empirical check at 884e8347d: schemaCacheTTL in authorable-surface/ = 0 (lit controls system/MigrationPlan: 4, kernel/PluginHealthCheck:interval 1; dark 0), so feat(spec)!: tenant schemaCacheTTL carries its unit in the key name (#17784) #17954 moving nothing and this PR moving three rows are the same rule read on nested vs top-level keys. I ran OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --check at head: exit 0 at 2026-09-13T10:43:43Z, "authorable-defaults/ verified against upstream 884e834 — 1170 default(s) unchanged (spec 门禁盲区:可作者化 key 的「默认值 / 约束」变更不被任何 gate、tombstone 或 conversion 记录(#4650 / #4659 同族) #4666)". Check (b) (live → retired must be registered by exact key) is satisfied by the three registry rows. CI job "Type Check · source gates" (id 103707942775) step 24 "authorable key surface is recorded and nothing vanished" completed: success — a job step, not the rollup.
  • Hand-written content/docs/protocol/kernel/lifecycle.mdx — TRUE at head. Every sentence naming these keys (lines 696, 706–709, 718, 792) now spells intervalMs / timeoutMs; line 708's "old spellings are refused at registerPlugin" is true of the code. The two shutdownTimeout: 30_000 sites (104, 836) are new ObjectKernel({...}) options, not HotReloadConfig, and are untouched correctly.
  • Pin tests — nothing deleted, weakened or skipped: 0 .skip/.only/.todo in the four test files; it( count 19→22 (spec), 21→22 (health-monitor), 16→18 (hot-reload). The two min-bound replacements are STRICTLY STRONGER: the old parse({ interval: 500 }).toThrow() would now stay green off the tombstone (a value on interval throws regardless of magnitude), while the new pins assert too_small on intervalMs: 500 / timeoutMs: 50 AND accept at the boundary (1000 / 100). Spec file run locally at head: 29 passed, exit 0, 2026-09-13T10:45:49Z.
  • check:doc-authoring — no baseline entry added: the diff is exactly 16 files, none under scripts/; node scripts/check-doc-authoring.mjs at head exit 0 ("no growth, no burn-down unrecorded", 2026-09-13T10:47:03Z); the two new core prescription strings carry no #NNNN, anchor in an adjacent // comment.
  • Generated content/docs/references/kernel/plugin-lifecycle-advanced.mdx — consistent with the new describes and tombstones; CI step 25 of job 103707942775 ("generated reference docs are in sync with the spec") completed: success.

② Semver level

minor / minor is RIGHT and the text matches the diff. Measured on origin/main (fc28c1d38) packages/spec/CHANGELOG.md at 2026-09-13T10:40:17Z: the four landed siblings — #15677 (line 124), #15680 (675), #15678 (1804), #15679 (2764) — all sit under ## 17.4.0### Minor Changes (the ### Patch Changes heading of that release starts at line 2988), each as feat(spec)! with a **BREAKING** banner and an adr-0087: registered … comment. So Ruling A's "patch" and its "following the #15678 / #15679 shape" cannot both hold; the shape is what shipped, and the PM's correction to minor is right by measurement. The convention that breaking-ness rides on the banner plus the disposition is stated in scripts/check-changeset-no-major.mjs (launch window ships breaking as minor; "purely additive widening … at least minor"); this changeset carries both: node scripts/check-adr-0087-registration.mjs → "[BREAKING+bang] registered kernel-health-check-and-hot-reload-durations-unit-in-key", exit 0; node scripts/check-changeset-no-major.mjs --base 884e8347d → no major bump, exit 0 (both 2026-09-13T10:43:11Z / 10:45:26Z); CI "Check Changeset" completed: success. @objectstack/core at minor is also right: its registration-time input contract narrows (old spellings now throw) and its readers move, and the three core refusal-row precedents (#12032, #12340, #12428) all shipped under ### Minor Changes in packages/core/CHANGELOG.md. Changeset body vs diff: the before/after table, the unchanged defaults 30000/5000/1000, the unchanged min bounds 1000/100/0, the D3-only disposition, the core refusals and the shutdownTimeout exclusion each correspond to a line in the diff.

③ Boundary flags

  • HotReloadConfig.shutdownTimeout "noted, not filed" — RIGHT to leave out of this card. JSDoc "Graceful shutdown timeout", describe "Maximum time to wait for graceful shutdown": no unit in either channel. The batch Add comprehensive test coverage for ObjectStack spec protocols - 100% coverage achieved #65 ruling 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 keeps unit-nowhere keys "listed, not judged", it is not one of the 21 rows the card partitions, and the census at head reads it [name: -] [prose: -] (check-duration-unit-keys.ts --list, line 413, 2026-09-13T10:43:02Z). Putting a unit into its describe would make it an offender under the standing rule and force a rename outside this card's rows. ESCALATED to the epic PM as a candidate follow-up card (default 30000, ms by magnitude), not a defect of this PR.
  • check-widening-tells T1 on the tombstone lines — CLAIM VERIFIED. node scripts/pm/check-widening-tells.mjs --declaration no --diff pr17986.diff (three-dot diff from 884e8347d) fires exactly 3 T1 rows, at plugin-lifecycle-advanced.zod.ts:198/199/383, all three the retiredKey( lines; the three new suffixed key lines were absorbed by the replacement budget against the three removed bare lines. T1's sentence ("the accept set gains a spelling an author may now write") is false of a never-typed tombstone, so the tell is inverted ([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). With --declaration yes the gate exits 0 (both runs 2026-09-13T10:46:41Z). The declaration is not what makes the diff acceptable, though: the diff DOES widen the accept set by three suffixed spellings, and that is what yes records.
  • Docs Drift Check, six pages, five claimed false positives — CONFIRMED on all five. From the bot comment the anchors are the bare timeoutMs / intervalMs literals in RETIRED_HEALTH_CHECK_KEYS. Grepped at head: automation/jobs.mdx, automation/webhooks.mdx, automation/flows.mdx, deployment/environment-variables.mdx, automation/hook-bodies.mdx each contain 0 PluginHealth mentions (lit control: lifecycle.mdx 9) and 0 debounceDelay; hook-bodies.mdx's two hot-reload hits (lines 57, 329) are Studio in-browser editing. The sixth, lifecycle.mdx, is the real hit and is edited.
  • Tracker ids in the two packages/core refusal tables — pre-existing, not this PR's. health-monitor.ts's three autoRestart destroys the plugin and never re-initialises it, then reports it recovering — and, once successThreshold binds, healthy #12032 rows are pinned as adjudicated population in scripts/doc-authoring-prose-id.baseline.json ("packages/core/src/health-monitor.ts": { "#12032": 3 }); hot-reload.ts carries none. The new rows follow the id-free rule and the guard is green. Burning the baseline down is maintainer-only; noted for whoever does.
  • Clause ② — node scripts/pm/check-clause2-carriers.mjs --pair 17986 → exit 0 verbatim: "the clause-② declaration is readable in the fixed spelling and both carriers agree" (2026-09-13T10:40:24Z). Independently: yes is correct on the mechanical floor. kernel/PluginHealthCheck and kernel/HotReloadConfig are published payloads (exported from @objectstack/spec/kernel, rows in the authorable-surface ledger, tables on the reference page) and intervalMs / timeoutMs / debounceDelayMs are spellings no author could write on them before. The ruling's no fails that floor; the correction is right. The PR's "neither def is an authorable surface" (D2 sense: no document carries them) and the ledger moving (mechanical sense: every generated def's top-level keys) use "authorable" in two meanings; both statements are true in their own sense and the PR should not be read as contradicting itself.
  • Cross-file staleness, searched at db55ea6dd (2026-09-13T10:40:33Z) across the whole tree excluding release-owned pages, CHANGELOGs, the generated registry and .changeset/:

Readings I re-measured

  • Three-dot diff 884e8347d..db55ea6dd: 16 files, +683/−76; single commit (2026-09-13T10:38:44Z).
  • JSDoc / describe / bounds / defaults for all three keys: read from the diff against 884e8347d; the three bare keys occur exactly once each in key position in the file at 884e8347d (lit control z.number 12; dark zzqqxx 0).
  • Family spelling at 884e8347d, packages/spec/src/**/*.zod.ts: *Ms key-position 100, timeoutMs 29, intervalMs 3 — matches the body exactly. debounceDelay key-position whole repo 6 (body says 5; the sixth is PHASE2_IMPLEMENTATION.md:71). debounceMs whole repo 6 (body says none — see ①).
  • Pinned objectui: fetched 53ded82bf7a494f54e344e19099dbf00854b8694 (= .objectui-sha at head) into scratch, 6409 tracked files; all thirteen exports of the file, debounceDelay, debounceDelayMs, plugin-lifecycle-advanced = 0 each; lit controls objectstack 10171, @objectstack/spec 3479, timeout 832, interval 156; dark zzqqxx 0 (2026-09-13T10:46:47Z) — every number matches the body.
  • Gates run locally at head: check:migration-registry 0; check:duration-unit-keys --list 0 offenders, the three renamed keys read [name: ms] [prose: ms]; check:adr-0087-registration 0; check-changeset-no-major --base 884e8347d 0; check:upgrade-guide 0; check:spec-changes 0; build-schemas.ts --check 0; check-doc-authoring 0; check-clause2-carriers --pair 17986 0; check-widening-tells yes → 0, no → exit 4 with 3 T1 rows on lines 198/199/383.
  • CI, read per job (completed: success, not the rollup): Lint & Repo Gates 103707942719 (completed 10:39:10Z, step 173 duration-unit gate and step 59 doc-authoring guard success); Type Check · source gates 103707942775 (steps 21, 22, 24, 25 success); Type Check · consumer gates 103707942720; Test Core (1/6…6/6) each success. TypeScript Type Check (3-second run) is an aggregator and was not used as evidence.
  • Taken on the body's word, not re-measured: the [prose: -] census verdict on origin/main for the three old describes (the describe strings themselves are verified from the diff; the gate's parse of "(default: 30s)" was not re-run at the merge-base); the full-suite counts 475 files / 13517 tests and 51 / 1316 (CI shard jobs green; I ran only the spec pin file, 29 passed); the 104 / 6 dispatch-gates reconciliation; the "16 asserting pin citations" line (CI step 35 of 103707942775 success).

Implemented-by: branch claude/issue-17780-plugin-lifecycle-duration-units
Reviewed-by: session_015c5G6TmpMKgnusmTpD7Ntt

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Carriers cleared — provenance

needs:contract-review stripped from both carriers at 2026-09-13T10:55Z: this PR and card #17780, each read back after the write.

review record #17986 (comment)
head judged db55ea6dde75d3e9f888d66cbad84c78c9006f0c — unchanged at strip time, so the PASS is not stale
verdict PASS, adopted verbatim
served tier verified from the review transcript's harness stamps: claude-fable-5-1 × 132, no other model value in the file, 0 harness fallback notices
check-clause2-carriers --pair 17986 exit 0
checks at strip time 42 runs, all success or skipped, 0 failure

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 5652827871.

Not enqueued yet. The label write above re-triggered a check run, so mergeable_state has gone unstable; ready and enqueue wait on that converging green rather than on a rollup. Ready → enqueue happens only after, and only in that order — on sibling PR #17954 I enqueued before stripping and the Governed Surface Queue Guard correctly dequeued it.

⛔ 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

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 <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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]>
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 size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: 3 duration key(s) in kernel/plugin-lifecycle-advanced.zod.ts name their unit only in JSDoc — #15939 Ruling A remediation (3 of the 21-row delta)

2 participants