feat(spec)!: the twelve api/ duration keys carry their unit in the key name (#15677, stack 2/6) - #15837
Conversation
…15676) Ruling B on #14478 exempts two structural classes from the duration-unit rule, and is explicit that both are declared ON THE SCHEMA, never in a gate ledger. This commit lands the declaration channels themselves: - `EpochMs` (`packages/spec/src/shared/epoch.zod.ts`) — the shared epoch-milliseconds instant. A key whose value IS this schema is an instant, not a duration, and `check:duration-unit-keys` recognises that structurally. - `.meta({ externalVocabulary: '<the standard>' })` — the marker a key carries when it mirrors a name fixed outside this repo. It rides `z.toJSONSchema` verbatim, the same channel `xRef` / `xExpression` already use. Neither exemption is a pass on lying: a marked key still fails `name-unit-contradicts-prose`, and an `EpochMs` key whose describe names a unit other than milliseconds fails the new `instant-unit-contradicts-schema`. Both classes stay visible in the census — `--list` marks them and the verdict line counts them. The gate also now reads `description` out of `.meta()`. Without it, moving a describe into `.meta({ description })` would take a key out of the population silently — an exemption by blindness. Measured: one numeric key declares its description that way today (`data/Field.precision`), naming no time unit, so the reading adds no offender. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…ternal-vocabulary keys (#15676) The two exemption classes ruling B declares, applied to the keys the gate lists. Instants (exemption i) — all six now typed `EpochMs`; the four whose name was bare are renamed to the `*At` instant convention, tombstoned with `retiredKey()` and registered in `RETIRED_KEYS_BY_MAJOR[18]` plus one D3 semantic entry: api/WebSocketEvent.timestamp -> occurredAt api/SimplePresenceState.lastSeen -> lastSeenAt kernel/KernelContext.startTime -> startedAt (+ TenantRuntimeContext) kernel/HealthStatus.timestamp -> checkedAt kernel/ServiceMetadata.registeredAt (already `*At`, schema only) kernel/ScopeInfo.createdAt (already `*At`, schema only) `*At` and not `*Ms`, measured rather than chosen: on this package's own authorable surface all 51 distinct `*Ms` keys are durations and all 51 distinct `*At` keys are instants, so spelling an instant `*Ms` would move it into the family the rule exists to separate it from. Semantic entries rather than D2 conversions because all four are runtime-emitted — wire payloads, a host-constructed kernel context, an emitted health report — so no conversion seam ever sees one. That is the disposition `kernel/KernelContext:previewMode` already carries on one of these defs, and what ruling B prescribes for a runtime-emitted key. External-standard mirrors (exemption ii) — eleven keys marked, not thirteen. Two of the thirteen the card attributed do not survive verification against their own schema and are left for their directory cards; the PR body records the evidence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…page (#15676) The published half of exemption (ii). A marked key keeps its bare name BECAUSE an external standard fixes it, and that argument only reaches the reference-page reader if the page names the standard — so the description cell now carries "(unit per <the standard>)". Without it the exemption would leave exactly the reader `check:duration-unit-keys` was filed for where the gate found them. Also: `EpochMs` gains its type alias (the docs import-surface ratchet demands one for every documented schema) and its ADR-0122 isomorphism pin. Regenerated: json-schema.manifest/, authorable-surface/, api-surface/, export-origins/, declaration-map/, content/docs/references/**. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…he tombstones caught (#15676) - `src/contracts/startup-orchestrator.test.ts` built a `HealthStatus` with the old `timestamp` key. The `retiredKey()` tombstone refused it at compile time (`Type 'number' is not assignable to type 'undefined'`, 7 errors) — the audible-removal property the tombstone exists for, working on the first consumer it met. - `type-alias-convention.pin.test.ts`: the pin count assertion follows the new `Iso868`. - Regenerated: export-origins/, declaration-map/, api-surface/ and the `objectstack-api` skill reference index (one generated line, naming the new `shared/epoch.zod.ts` module). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…ellings (#15677) The schema half of stack card 2/6. Gate reads 48 -> 36 with src/api/ at 0. Readers, registry entries and regenerated artifacts follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…enames (#15677) 12 retired-key entries, one D2 conversion (api-endpoint-cache-ttl-to-cache-ttl-seconds — apis: is a stack collection) and five semantic entries for the eleven runtime-emitted / construction-argument keys. Readers moved in runtime, metadata, rest-adjacent tests, dogfood fixtures and the showcase example; liveness/api.json carries the renamed row plus the dead tombstone row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…acts (#15677) Per-key refusal tests assert the prescription (code + rename text), not a bare throw. Two readers the key-name grep missed and tsc/the tombstones caught: the ApiEndpoint alias table (cacheTTL/ttl/cache retargeted onto cacheTtlSeconds — an alias must point at a key the schema accepts) and the showcase endpoint fixture in metadata-type-api-registration.test.ts. Regenerated: authorable surface + defaults, reference docs, liveness state-counts. skills/objectstack-api/SKILL.md carries the rename (governed; net 0 lines, file and package both). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
@objectstack/spec minor with the BREAKING banner naming every renamed key, the six adr-0087 ids registered, the retryAfter wire note, and the disposition split (one D2 conversion, five semantic entries). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
CI red — root-caused. It is this PR's, one cause trips two pins
The mechanism
This PR's new D2 conversion carries a faithful fixture: { name: 'list_tasks', path: '/api/v1/apps/showcase/tasks', method: 'GET', … }
{ name: 'create_task', path: '/api/v1/apps/showcase/tasks', method: 'POST', … }
One cause, two pins. And it is this PR's: the same check reported success on this PR's base (card 1/6, #15814), and Disposition⛔ Not by weakening the fixture. Dropping ⛔ Not by weakening either pin. They are doing precisely what their comments say they exist for: "the day a ledger starts declaring a The scanner is over-broad: a conversion registry is replay-and-fixture data, not a route surface, by construction — and its exclusion today is incidental (no method signal happened to appear) rather than declared. The fix is to declare it: kind (b) skips Sent to the card's dev with that reasoning, and with the question left genuinely open rather than pre-decided: ⛔ Independent of the outcome: this PR stays a draft, is not enqueued, gets no auto-merge, and is not approved by any seat. Generated by Claude Code |
Seat verification — independently reproduced, and it holds on every axisNot a re-reading of the PR body: a separate Exit 1, by design. ⭐ The 12 left by rename, not by a new exemptionThe exemption header is identical to the base's — ⭐ It did not move a population it does not ownThis was the reading I cared about most, because a card that clears its own directory by touching a neighbour's hands the next dev a target that no longer exists.
Nothing differs. Every non- The twelve renames, checked individuallyAll twelve old spellings survive only as Governance and the Prime Directive
What remainsOnly the ⛔ Draft, not enqueued, no auto-merge, not approved by any seat. Generated by Claude Code |
…ied on incidentally (#15677) An ADR-0087 conversion fixture copies a routable metadata kind verbatim, so an `apis:` fixture carries `method:` beside `path:` — because that is what an ApiEndpoint IS. Ruling A named conversions/registry.ts as the guard's target but enforced it with requireMethodSignal, a content proxy that held only while no conversion fixture carried a verb. This card's apis: conversion is the first that does, and the live pin red exactly as designed. The fixture is correct and stays. The exclusion moves to CONVERSION_REPLAY_FILE_RE, which states the structural fact instead of testing a symptom, and three cases pin the new guard as load-bearing rather than incidental in its turn. NOT restricting kind (b) to packages/spec/src/api/**: that is the invariant the live pin asserts, and enforcing it in the walk would make that pin true by construction — a check that cannot fail. Measured tail-neutral: the scan census is byte-identical to the base (17 route sources, 12 call sites, 5 contract declarations, 78 tails, 61 reachable). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
📓 Docs Drift CheckThis PR changes 2 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 135 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 1a066680b270fb6247df33a02075cd65e1f988bb && git checkout 1a066680b270fb6247df33a02075cd65e1f988bb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c09fd102342111ac4949f9d9a8d797c12094669e dcd1be0dba5768b50b15effe05c17bbd383c891d && git checkout -B drift-repro c09fd102342111ac4949f9d9a8d797c12094669e && git merge --no-ff dcd1be0dba5768b50b15effe05c17bbd383c891d
node scripts/docs-audit/affected-docs.mjs --json c09fd102342111ac4949f9d9a8d797c12094669e
|
Second finding — the hand-written docs still teach the old spellingsMeasured on the new head First, what is intact: That last fact is the gap. This card's execution note requires "liveness ledgers, forms, i18n bundles, docs and
|
| page | lines |
|---|---|
content/docs/api/declarative-endpoints.mdx |
:75 (cacheTtl: 30, — an authoring example), :191 ("is seconds and cannot be negative"), :192 ("is GET-only"), :196, :246 |
content/docs/getting-started/quick-reference.mdx |
:313 (a reference-table row), :329 (cacheTtl: 30,) |
content/docs/protocol/kernel/http-protocol.mdx |
:1202, :1216, :1231, :1232, :1266 |
These are authoring examples and authoring rules for a key this PR turned into a tombstone. A reader who follows them writes a key the parse now refuses — a doc that does not merely go stale but actively produces a failure. It is the ApiEndpoint alias-table lesson this PR already learned, one layer up: the alias table was caught by tsc, and prose has no tsc.
⚠️ retryAfter — 19 occurrences, and they are three different things
⛔ This one must not be swept. The distinction is the one this PR's own body, tombstone prose, semantic entry and dedicated test all exist to protect:
- ADR-0112 envelope field ⇒ stale.
api/error-catalog.mdx:47,:390,:395,:651,:717,:769;api/error-handling-client.mdx:31,:72,:82,:244,:269,:273. - ⛔ HTTP
Retry-Afterresponse header ⇒ leave alone.protocol/kernel/error-handling.mdx:450-451andprotocol/kernel/http-protocol.mdx:1319, bothresponse.headers.get('Retry-After'). Renaming these is precisely the mistake the PR warns the next reader against. - ⛔
retry_aftersnake_case wire detail ⇒ a third surface.protocol/kernel/error-handling.mdx:734, andapi/error-catalog.mdx:47names it as an error code beside the field.
Also flagged for judgement rather than assumption: protocol/kernel/http-protocol.mdx:1126 already reads "retryAfterSeconds": 45 inside details — either a pre-existing different key or something this change should align with. ⛔ Not assumed either way.
Disposition
Sent to the dev with the classification, not with a rename instruction: update only the occurrences naming the renamed authorable/envelope key, and defend any occurrence left unchanged rather than leaving it silent.
⛔ content/docs/releases/v17.mdx appears in the drift bot's ⛔ block because it names something this diff touched — affected, not editable. If it is factually wrong that is a dedicated docs-only PR or an issue, never a rider on this one.
Nothing here changes the verified gate result above: api/ 0, tree 36, no neighbouring population moved. This is a docs-completeness gap inside the card's declared scope, not a defect in the rename.
Generated by Claude Code
…e issue ids (#15677) Lap 1 regenerated content/docs/references/** but left the HAND-WRITTEN pages teaching the old spellings. Three of them carried `os:check` blocks authoring `cacheTtl`, so check:skill-examples was RED and lap 1 never ran it — it sits in check:generated's "not run here" list and I did not run it separately. cacheTtl -> cacheTtlSeconds: 14 occurrences on 13 lines, all the ApiEndpoint key. retryAfter -> retryAfterSeconds: 14 occurrences, the ADR-0112 envelope field only. Deliberately NOT swept, each verified rather than assumed: - the HTTP `Retry-After` response header (6 locals over 4 sites) — RFC 9110, a separate surface, and the thing the tombstone prose exists to protect; - `retry_after` as a RetryStrategy ENUM VALUE (errors.zod.ts z.enum); - `details.retry_after` on the wire, and the pre-existing `details.retryAfterSeconds` the runtime really emits (endpoint-policy.ts). Also strips `(#14478 ruling B)` from the twelve tombstone prescriptions THIS card wrote: check:doc-authoring forbids an internal issue id in customer-facing spec text (maintainer ruling 2026-08-12), and the campaign's own earlier tombstones already comply. The version and the FROM -> TO mapping stay — those are the durable references AGENTS.md requires. Measured: the gate read 4 findings on the base and 16 on my head; it now reads the base's 4 again, so this PR adds none. Those 4 are card 1/6's (PR #15814) and are not mine to touch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
Seat review of both fixes — verified on
|
…mbstone prescriptions (#15676) `check:doc-authoring` rule 3 forbids an internal issue id in customer-facing spec text (maintainer ruling 2026-08-12). The four `retiredKey()` prescriptions this stack card added each opened with a `(#14478 ruling B)` parenthetical — a citation-shaped token that resolves to nothing for the author who meets it the moment their key is refused, and which the generated reference pages publish verbatim. The parenthetical goes; the sentence keeps everything actionable it carried — the FROM to TO rename, that the value is unchanged, and the `SimplePresenceState.lastSeen` neighbour caveat — matching the shape the campaign's already-compliant tombstones use (`hook.timeout`, `job.timeout`, `DriverOptions.timeout`). The internal anchor is untouched in the adjacent `//` and `/** */` comments, which are not customer-facing and were never findings. `content/docs/references/**` regenerated with `pnpm --filter @objectstack/spec gen:docs` — no generated artifact was hand-edited. check:doc-authoring: 4 findings before, exit 0 after. check:duration-unit-keys: unmoved — 48 offender(s) among 215 duration-shaped numeric key(s), (6 declared `EpochMs` instant(s), 11 declared `externalVocabulary` mirror(s)). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…external-vocabulary-exemptions' into claude/issue-15677-api-duration-unit-in-key-name
The merge with card 1/6's advanced tip (9605808) touched api/websocket.zod.ts on both sides. The schema source auto-merged; the generated content/docs/references/api/websocket.mdx is routed to merge=os-regen, so the driver deferred it and the merge kept OUR side — silently dropping card 1/6's half. Regenerating from the merged tree is what repairs it, and it carries both sides: their two stripped prescriptions land (issue-id occurrences 2 -> 0) while my four renamed keys stay (6 -> 6). Not hand-edited and not resolved by taking a side: the bytes come from `pnpm --filter @objectstack/spec check:generated --fix` on the merged tree, and the staged diff was read before committing (`git diff` reads clean over this trap; only the staged diff shows it). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
Card 1/6 landed on the stack base branch as a SQUASH, which keeps no ancestry link, so git saw card 1/6's content applied twice and reported four conflicts that were duplicate-content artifacts rather than real disagreements. Resolution: - packages/spec/src/migrations/registry.ts (hand-written) took this branch's side: both conflict hunks had an EMPTY incoming side, and every one of the 119 lines the base added to this file is already present here. - The three os-regen artifacts (authorable-surface/api.json, the two api/ reference pages) were deferred by the merge driver and are regenerated in the commit that follows this one. The merge is not content-neutral: the base carries four post-sync fixes from card 1/6 that this branch had not absorbed, 7 lines in all - the two TSDoc examples that still spelled the renamed epoch instants (occurredAt, checkedAt) and the two schema-count lines (quick-reference.mdx, llms.txt). Nothing on this branch is lost or changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
Part of #14478
Stack card 2/6 — #15677 (
api/). Not closed by merging this; the stack lands together.Stacked on card 1/6's branch (PR #15814). Base is
claude/issue-15676-epoch-ms-and-external-vocabulary-exemptionsatc1949b9ac0286c211100d836ea6082e29542a3de, notmain—check:duration-unit-keysand theEpochMs/externalVocabularymachinery exist only there.What this does
Clears
src/api/**againstcheck:duration-unit-keys: twelve duration-shaped keys whose unit lived only in the.describe()prose now carry it in the key name. Every value is unchanged; only names move.Measured with the gate itself, exit code captured before any pipe, on my own head:
c1949b9ac)check:duration-unit-keys — 48 offender(s) among 215 duration-shaped numeric key(s) in 769 source file(s) (6 declared EpochMs instant(s), 11 declared externalVocabulary mirror(s))— exit 1, 12 rows undersrc/api/8cd4d8cba)check:duration-unit-keys — 36 offender(s) among 215 duration-shaped numeric key(s) in 786 source file(s) (6 declared EpochMs instant(s), 11 declared externalVocabulary mirror(s))— exit 1, 0 rows undersrc/api/48 → 36,
api/at 0 — the card's stated numbers. The gate still exits 1 by design: 36 offenders remain inkernel/,system/,data/,ui/,ai/,integration/, which are cards 3/6–5/6. The file count moves 769 → 786 because this PR adds 17 registry entry files undersrc/migrations/entries/.FROM → TO
api/auth-endpoints.zod.tsDeviceRequestResponse.intervalintervalSecondsapi/contract.zod.tsDataLoaderConfig.cacheTtlcacheTtlSecondsapi/endpoint.zod.tsApiEndpoint.cacheTtlcacheTtlSecondsapi/errors.zod.tsEnhancedApiError.retryAfterretryAfterSecondsapi/plugin-rest-api.zod.tsRestApiEndpoint.timeouttimeoutMsapi/plugin-rest-api.zod.tsRestApiEndpoint.cacheTtlcacheTtlSecondsapi/plugin-rest-api.zod.tsRestApiPluginConfig.performance.defaultCacheTtldefaultCacheTtlSecondsapi/router.zod.tsRouteDefinition.timeouttimeoutMsapi/websocket.zod.tsWebSocketConfig.reconnectIntervalreconnectIntervalMsapi/websocket.zod.tsWebSocketConfig.pingIntervalpingIntervalMsapi/websocket.zod.tsWebSocketConfig.timeouttimeoutMsapi/websocket.zod.tsWebSocketServerConfig.heartbeatIntervalheartbeatIntervalMsEach old spelling is a
retiredKey()tombstone:tscrefuses it at the authoring site (input typenever) and the parse refuses it with the rename prescription, never a bare unrecognized-key error.Dispositions — justified per key, not defaulted
One D2 conversion.
ApiEndpoint.cacheTtlis the only one of the twelve on a stack collection:stack.zod.tsdeclaresapis: z.array(ApiEndpointSchema)andapiis a registered metadata kind stored as a row, so the conversion chain has a seam that sees it.api-endpoint-cache-ttl-to-cache-ttl-seconds, retired from the load path, with a copy-on-write fixture. Its tombstone is the only one that closes with the houseos migrate meta --from 17sentence, because it is the only one whose surface a conversion covers.Five semantic entries for the other eleven — wire payloads and construction arguments the chain never touches:
device-request-response-interval-unit-in-key,api-error-retry-after-unit-in-key,rest-api-plugin-durations-unit-in-key,websocket-durations-unit-in-key,api-runtime-config-durations-unit-in-key. Grouped by surface so an upgrading reader finds one entry per thing they author. This is the dispositionapi/RestApiEndpoint:handlerStatusalready holds on one of these very shapes.12
RETIRED_KEYS_BY_MAJOR[18]rows, one per tombstone.intervalis a rename, and deliberately not a markerCard 1/6 attributed
DeviceRequestResponse.intervalto RFC 8628 and demoted it on the schema's own evidence. Re-checked here and it holds:DeviceRequestResponseSchemadoes not mirror RFC 8628 as a set —codeis notdevice_code,verificationUrlis notverification_uri,expiresAtis notexpires_in(a different name and a different type: an ISO-8601 string where the RFC carries a relative lifetime). A schema that has already renamed every RFC field it carries into house style cannot claim the standard fixes the one name it left bare. Renamed, not marked: a wrongly marked key is exempted permanently and silently, a wrongly renamed one is visible. The evidence is recorded in the semantic entry rather than only here.retryAfterand its BREAKING noteRuled explicitly in scope with its own BREAKING note. On one 429 a consumer meets two retry-after values: this ADR-0112 envelope field, always delta-seconds, and the HTTP
Retry-Afterheader, which per RFC 9110 §10.2.3 may carry delta-seconds or an HTTP-date. Spelled identically they read as one value in two places.The HTTP
Retry-Afterresponse header is a separate, unchanged surface. Its name is fixed outside this repo and nothing here touches it. Do not "fix" the header to match, and do not read a survivingretry-afterin transport code as leftover work. Said in the tombstone prose, in the semantic entry and in a dedicated test, so the next reader meets it wherever they arrive.Readers moved in the same PR, at the same magnitude
Grepped by TYPE, not by name — and two readers a key-name grep missed were caught by
tscand the tombstones, exactly the lesson this card carries:ApiEndpointalias table (endpoint.zod.ts):cacheTTL/ttl/cachepointed atcacheTtl, which now accepts nothing.alias-integrity.test.tsfailed withcacheTtl is a tombstone; it accepts nothingand the table is retargeted onto the live key.metadata-type-api-registration.test.ts, a showcase endpoint fixture authoringcacheTtl.Also moved:
@objectstack/runtime's policy chain (computeCacheControlreadsendpoint.cacheTtlSeconds), the publish gate's issue path (apis.N.cacheTtlSeconds), the six built-in REST route tables inplugin-rest-api.zod.ts(14 producer sites), the showcase example, dogfood fixtures,docs/qa/platform-checklist/areas/api-backend.json,packages/spec/REST_API_PLUGIN.md, andliveness/api.json— renamed row with its evidence repointed, plus adeadtombstone row, following thehook.timeoutMs/hook.timeoutprecedent.Governed —
skills/objectstack-api/SKILL.mdauthors this key, so it is kept rather than dropped. Readings: the file is 428 → 428 lines and the publishedskills/**package is 6857 → 6857 lines across 11 files. Net zero both ways: an in-place rename, withsecondsdropping out of the trailing comment because the key now carries it.Left alone deliberately:
RestServerConfig.metadata.cacheTtlinpackages/restand its checklist rows — a different key, retired for a different reason by #14691.docs/adr/0121keeps its prose as a dated decision record, which is what the #15626 rename did.Verification — heavy CI does not run on this PR
lint.ymlandci.ymlboth declarepull_request: branches: [main], so a PR based on a feature branch triggers neither and none of the 7 required contexts will report on this head. Every number below is a local measurement.Run under the shared verify lock, exit codes captured before any pipe (
cmd > out 2>&1; EXIT=$?), never through| heador| tail:pnpm --filter @objectstack/spec test— 12759 tests, 473 files, all green on the final tree.pnpm --filter @objectstack/spec typecheck— green.pnpm --filter @objectstack/runtime --filter @objectstack/metadata --filter @objectstack/rest test— 3243 + 718 + 3086 green; same threetypecheckgreen.pnpm --filter @objectstack/dogfood --filter @objectstack/example-showcase typecheck— green after building their dependency closure. The first run reportedTS2307 Cannot find moduleacross the board: that is a NOT-MEASURED reading, not a red gate, and it is recorded here rather than reported as a pass.pnpm --filter @objectstack/spec check:generated— all 15 artifacts current.check:nul-bytes,check:adr-0087-registration,check:empty-changeset,check:changeset-gate-self-tests,check:pm-skill-ratchet,check:pm-governed-prose,check:skill-identifier-liveness,check:examples-live-imports,check:cross-package-test-inputs,check:error-code-casing— all exit 0.check-adr-0087-registrationprints all six ids as registered on this changeset.Reverse verification
Predicted before running: reverting one rename at the schema takes the gate 36 → 37 with a new named
src/api/errors.zod.tsrow, and the three newerrors.test.tscases go red.Mutation:
retryAfterSecondsput back to bareretryAfterand the tombstone deleted, against a committed implementation. Proven on disk, not by an exit code — the file blob moveddabf299d→0100d8ed, with the injected key counted at 1, the live key at 0 and the tombstone at 0.Observed: gate exit 1,
37 offender(s), row[unit-in-prose-not-in-name] src/api/errors.zod.ts:407 retryAfter. Tests: 4 red, one more than predicted — the three new cases plus the pre-existingshould accept rate limit error with retry info, which this PR had moved onto the new spelling. The prediction under-counted by one and the fourth is accounted for rather than quietly absorbed.No rebuild leg:
check:duration-unit-keysscanspackages/spec/src/**as text (its own self-test asserts the declared population is that subtree) and the spec tests import their schemas by relative source path, so nodist/sits between the mutation and either measurement.Restore proven by state: blob hash back to
dabf299d(equal to theHEADblob) andgit diff HEADzero bytes. The script carried atrapwith an absoluteREPO_ROOTpath and restored viagit checkout HEAD -- path, never a baregit checkout -- path.Declared narrowing
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackderives its change set three-dot againstorigin/main, so on a stacked branch it returns 171 paths — the whole stack, not this card's 64 files, and a family far wider than this diff. It also warns the tree is at least 34 commits behindorigin/mainwith 37 gate scripts changed in that range, which is by construction for a card required to branch off 1/6's head rather than main. So the gate set above is narrowed to the gates that read the files this PR changes, and the narrowing is declared here rather than left implicit. The repo-wide sweeps are CI's — with the caveat, stated above, that CI will not run them here either. Integration is where they land.The
affected-docspin this PR red, and why the fix is hereFlag docs affected by code changeswent red on8cd4d8cbacwith two live self-test pins inscripts/docs-audit/affected-docs.mjs. Both are one cause, and the cause is this PR.Confirmed by measurement, not inherited. Reverting only
packages/spec/src/conversions/registry.tsto its base blob (3984265710for5dad0b918c) takes the check to✓ affected-docs self-test: 582 cases pass., exit 0. So the D2 conversion's fixture is the whole cause.Why it fired. Ruling A (2026-09-04, batch #31) named
conversions/registry.tsas kind (b)'s guard target and excluded it withrequireMethodSignal— "is there an HTTP verb next to the path". That is a content proxy for a structural fact, and it held only while no conversion fixture carried a verb. An ADR-0087 fixture copies a routable metadata kind verbatim, so anapis:fixture carriesmethod: 'GET'besidepath: '/api/v1/apps/showcase/tasks'— because that is what anApiEndpointis. This is the first conversion over a routable kind, so it is the first fixture to defeat the proxy. The pin red instead of silently minting a phantom route source, which is exactly what its comment says it exists for.What I did not do. Not weaken the fixture: dropping
methodwould make it unfaithful to a conversion whose own summary says the key "stays GET-only", and would only defer the failure to the next such fixture. Not weaken, delete or retarget either pin.The fix:
CONVERSION_REPLAY_FILE_RE— kind (b) skipspackages/spec/src/conversions/**structurally, and pin (1)'s rationale now says the exclusion is declared rather than riding on a symptom.Deliberately NOT the other available fix. Restricting kind (b) to
packages/spec/src/api/**looks tidier and would fix both pins at once — but that is precisely the invariant pin (2) asserts (every contract declaration admitted is a packages/spec API declaration). Enforcing it in the walk would make that pin true by construction: a check that can never fail, over the one population this route is most likely to widen by accident. The self-test file already guards pin (2) with an anti-vacuity floor for exactly this reason. The pin is worth more than the tidier rule.Measured tail-neutral. The scan census is byte-identical with the guard and my fixture to what it was on the base with neither: 17 route sources (12 call sites, 5 contract declarations), 78 tails, 290/290 ledger rows, 219/219 client-bound, 61 reachable, 158 unreachable. The guard costs nothing.
The new guard is pinned load-bearing, not left incidental in its turn — three added cases, and an ablation to prove they bite: deleting only the guard's use site reproduces exactly the original two failures (blob
9285076a→e9c846a0, restored to9285076awithgit diff HEADzero bytes).Verification on
d7ebd6c7a:node scripts/docs-audit/check-affected-docs.mjs→ exit 0,✓ affected-docs self-test: 585 cases pass.check:docs-audit-scope,check:docs-transcript-drift,check:nul-bytes,check:adr-0087-registration,check:empty-changesetall exit 0, and the four families the added path newly derives (check:bash32-floor,check:cli-command-ids,check:entry-guard,check:parse-guard) all exit 0.check:duration-unit-keysis unchanged: 36 offenders,api/at 0.scripts/docs-audit/**is gate-class and normallydomain:devx, so this PR is cross-lane. I judged it belongs here because this PR broke the pin and the stack is serial — 3/6 to 5/6 branch downstream and would each inherit a red advisory gate — and because the correct form was already pinned down by Ruling A naming the file. No open PR holdsscripts/docs-audit/**(measured across all 32 open PRs). If the maintainer prefers it split, it lifts out as one commit.Hand-written docs, and a red gate lap 1 never ran
Lap 1 regenerated
content/docs/references/**and stopped there. The hand-written pages still taught the old spellings — and three of them carryos:checkblocks, so this was not cosmetic:That is the tombstone's
tscchannel firing on documentation.check:skill-exampleswas red and lap 1 did not run it — it sits incheck:generated's "not run here" list and I did not run it separately. Recording that as a gap in lap 1's coverage, not as something the docs edit merely tidied.Now:
✅ 257 prose examples type-check across 3 surface(s), exit 0.What moved, and what deliberately did not
cacheTtl— theApiEndpointkeycacheTtlSecondsretryAfter— the ADR-0112 envelope fieldretryAfterSecondsRetry-Afterresponse headerretry_afteras aRetryStrategyenum valuedetails.retry_afteron the wiredetails.retryAfterSeconds(http-protocol.mdx:1126)Each exclusion was verified rather than assumed.
retry_afteris a real enum member (errors.zod.ts—z.enum(['no_retry', 'retry_immediate', 'retry_backoff', 'retry_after'])). Thedetails.retryAfterSecondsathttp-protocol.mdx:1126is not something to align this rename to: it is adetailsmember, it was already suffixed before this PR, and it is what the runtime actually emits (endpoint-policy.ts—details: { retryAfterSeconds: retryAfterSec, … }). Counts confirm the exclusions held:Retry-After12 → 12,retry_after9 → 9.content/docs/releases/— 0 files changed, verified on the final head.check:doc-authoring: this PR now adds zero findingsRunning the docs family surfaced a second red.
check:doc-authoringforbids an internal issue id in customer-facing spec text (maintainer ruling 2026-08-12, verbatim: 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」). Every tombstone prescription this card wrote carried(#14478 ruling B).Measured on three trees rather than assumed:
c1949b9ac(card 1/6's head)So the gate was already red on my base and this PR grew it by 12; it now reads the base's population exactly. The campaign's own earlier tombstones (
hook.timeout,job.timeout,DriverOptions.timeout) already comply and were never flagged — I had copied card 1/6's pattern instead of theirs. The version and the FROM → TO mapping stay, which is what AGENTS.md requires a prescription to carry.The remaining 4 are card 1/6's (
websocket.zod.ts×2,kernel/context.zod.ts,kernel/startup-orchestrator.zod.ts) and belong to PR #15814, which this card must not touch. I initially stripped two of them by over-broad substitution and restored them byte-for-byte before committing; the websocket diff now touches only my own keys. They need routing to card 1/6.Re-verified after the prose change, since
retiredKeyguidance feeds.describe()and therefore the reference pages:check:generatedexit 0 (all 15 current,gen:docsre-run),pnpm --filter @objectstack/spec test473 files / 12760 tests, all passing,check:duration-unit-keysunchanged at 36 withapi/at 0,check:doc-anchors/check:docs-single-h1/check:docs-redirects/check:docs-image-tag/check:docs-transcript-drift/check:docs-audit-scope/check:corpus-claim-drift/check:nul-bytesall exit 0, andcheck:doc-formula-expressions/check:doc-security-postureexit 0 under their real invocation (pnpm --filter @objectstack/lint run …— as plainpnpm check:*they return 254, command-not-found, which is not a measurement).Merge lap — card 1/6's branch advanced under this one
Base branch moved
c1949b9ac→960580850(card 1/6's follow-up stripping the four#14478citations this PR's lap 3 routed to it). Merged in as a merge commit — no rebase, no amend, no force-push.One conflict, and it is generated. Reproduced with the driver-disabled form, because a plain
merge-treeon this repo can return a false clean through theos-regendriver:Locally the driver behaves differently and that difference is the hazard:
git mergeexited 0 and deferred the page — so the merge commit kept our side and silently dropped card 1/6's. Regeneration is what repairs it, and the acceptance test was stated before running it: the page must lose card 1/6's issue-ids and keep my renamed keys.(#14478 ruling B)occurrences (card 1/6's side)reconnectIntervalMs/pingIntervalMs/heartbeatIntervalMs(my side)Both sides present. ⛔ Not hand-edited, ⛔ not resolved by taking a side: the bytes come from
check:generated --fixon the merged tree, run after the merge was committed — never in MERGE state, wheregen:schemasilently rolls the authorable-surface anchor back to the old fork point. The staged diff was read before committing (plaingit diffreads clean over exactly this trap) and contains only card 1/6's two prescriptions returning. Theos-regenpre-commit hook then confirmed it:✓ content/docs/references/api/websocket.mdx — current · marker cleared.Nothing of card 1/6's was altered; their four prescriptions arrive through the merge and are correct as they arrive.
Re-verified on the merged head
b4633903bcheck:duration-unit-keysapi/at 0, same exemption header (6EpochMs, 11externalVocabulary). Unchanged by the mergecheck:generatedcheck:doc-authoringpnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec typecheckcheck:skill-examplescheck:nul-bytes,check:adr-0087-registration,check:empty-changeset,check:docs-audit-scope,check:doc-anchors,check:docs-single-h1,affected-docscontent/docs/releases/And the conflict is gone at its source:
git -c merge.os-regen.driver=false merge-tree --write-tree --name-only 960580850 HEAD→ exit 0.git merge-base --is-ancestor 960580850 HEAD→ exit 0, which is the self-proving direction on any checkout.mergeable_state: cleanthroughout — against the base sha recorded at PR creation, not the branch tip. That reading was never evidence and is not what any number above rests on.Not touched
kernel/system/data/ui/ai/integration/offenders (cards 3/6–5/6), the gate's declared population (card 6/6), PR #15814's diff, andcontent/docs/releases/.Generated by Claude Code