Skip to content

feat(spec)!: the twelve api/ duration keys carry their unit in the key name (#15677, stack 2/6) - #15837

Open
claude[bot] wants to merge 15 commits into
claude/issue-14478-duration-unit-in-key-namefrom
claude/issue-15677-api-duration-unit-in-key-name
Open

feat(spec)!: the twelve api/ duration keys carry their unit in the key name (#15677, stack 2/6)#15837
claude[bot] wants to merge 15 commits into
claude/issue-14478-duration-unit-in-key-namefrom
claude/issue-15677-api-duration-unit-in-key-name

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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-exemptions at c1949b9ac0286c211100d836ea6082e29542a3de, not maincheck:duration-unit-keys and the EpochMs / externalVocabulary machinery exist only there.

What this does

Clears src/api/** against check: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:

verdict line
before (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 under src/api/
after (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 under src/api/

48 → 36, api/ at 0 — the card's stated numbers. The gate still exits 1 by design: 36 offenders remain in kernel/, 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 under src/migrations/entries/.

FROM → TO

site key replacement
api/auth-endpoints.zod.ts DeviceRequestResponse.interval intervalSeconds
api/contract.zod.ts DataLoaderConfig.cacheTtl cacheTtlSeconds
api/endpoint.zod.ts ApiEndpoint.cacheTtl cacheTtlSeconds
api/errors.zod.ts EnhancedApiError.retryAfter retryAfterSeconds
api/plugin-rest-api.zod.ts RestApiEndpoint.timeout timeoutMs
api/plugin-rest-api.zod.ts RestApiEndpoint.cacheTtl cacheTtlSeconds
api/plugin-rest-api.zod.ts RestApiPluginConfig.performance.defaultCacheTtl defaultCacheTtlSeconds
api/router.zod.ts RouteDefinition.timeout timeoutMs
api/websocket.zod.ts WebSocketConfig.reconnectInterval reconnectIntervalMs
api/websocket.zod.ts WebSocketConfig.pingInterval pingIntervalMs
api/websocket.zod.ts WebSocketConfig.timeout timeoutMs
api/websocket.zod.ts WebSocketServerConfig.heartbeatInterval heartbeatIntervalMs

Each old spelling is a retiredKey() tombstone: tsc refuses it at the authoring site (input type never) 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.cacheTtl is the only one of the twelve on a stack collection: stack.zod.ts declares apis: z.array(ApiEndpointSchema) and api is 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 house os migrate meta --from 17 sentence, 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 disposition api/RestApiEndpoint:handlerStatus already holds on one of these very shapes.

12 RETIRED_KEYS_BY_MAJOR[18] rows, one per tombstone.

interval is a rename, and deliberately not a marker

Card 1/6 attributed DeviceRequestResponse.interval to RFC 8628 and demoted it on the schema's own evidence. Re-checked here and it holds: DeviceRequestResponseSchema does not mirror RFC 8628 as a set — code is not device_code, verificationUrl is not verification_uri, expiresAt is not expires_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.

retryAfter and its BREAKING note

Ruled 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-After header, 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-After response 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 surviving retry-after in 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 tsc and the tombstones, exactly the lesson this card carries:

  • The ApiEndpoint alias table (endpoint.zod.ts): cacheTTL / ttl / cache pointed at cacheTtl, which now accepts nothing. alias-integrity.test.ts failed with cacheTtl is a tombstone; it accepts nothing and the table is retargeted onto the live key.
  • metadata-type-api-registration.test.ts, a showcase endpoint fixture authoring cacheTtl.

Also moved: @objectstack/runtime's policy chain (computeCacheControl reads endpoint.cacheTtlSeconds), the publish gate's issue path (apis.N.cacheTtlSeconds), the six built-in REST route tables in plugin-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, and liveness/api.json — renamed row with its evidence repointed, plus a dead tombstone row, following the hook.timeoutMs / hook.timeout precedent.

Governedskills/objectstack-api/SKILL.md authors this key, so it is kept rather than dropped. Readings: the file is 428 → 428 lines and the published skills/** package is 6857 → 6857 lines across 11 files. Net zero both ways: an in-place rename, with seconds dropping out of the trailing comment because the key now carries it.

Left alone deliberately: RestServerConfig.metadata.cacheTtl in packages/rest and its checklist rows — a different key, retired for a different reason by #14691. docs/adr/0121 keeps its prose as a dated decision record, which is what the #15626 rename did.

Verification — heavy CI does not run on this PR

lint.yml and ci.yml both declare pull_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 | head or | tail:

  • pnpm --filter @objectstack/spec test12759 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 test3243 + 718 + 3086 green; same three typecheck green.
  • pnpm --filter @objectstack/dogfood --filter @objectstack/example-showcase typecheck — green after building their dependency closure. The first run reported TS2307 Cannot find module across 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-registration prints 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.ts row, and the three new errors.test.ts cases go red.

Mutation: retryAfterSeconds put back to bare retryAfter and the tombstone deleted, against a committed implementation. Proven on disk, not by an exit code — the file blob moved dabf299d0100d8ed, 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-existing should 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-keys scans packages/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 no dist/ sits between the mutation and either measurement.

Restore proven by state: blob hash back to dabf299d (equal to the HEAD blob) and git diff HEAD zero bytes. The script carried a trap with an absolute REPO_ROOT path and restored via git checkout HEAD -- path, never a bare git checkout -- path.

Declared narrowing

scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derives its change set three-dot against origin/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 behind origin/main with 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-docs pin this PR red, and why the fix is here

Flag docs affected by code changes went red on 8cd4d8cbac with two live self-test pins in scripts/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.ts to its base blob (3984265710 for 5dad0b918c) 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.ts as kind (b)'s guard target and excluded it with requireMethodSignal — "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 an apis: fixture carries method: 'GET' beside path: '/api/v1/apps/showcase/tasks' — because that is what an ApiEndpoint is. 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 method would 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) skips packages/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 9285076ae9c846a0, restored to 9285076a with git diff HEAD zero bytes).

Verification on d7ebd6c7a: node scripts/docs-audit/check-affected-docs.mjsexit 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-changeset all 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-keys is unchanged: 36 offenders, api/ at 0.

⚠️ One reviewer note, stated rather than buried: scripts/docs-audit/** is gate-class and normally domain: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 holds scripts/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 carry os:check blocks, so this was not cosmetic:

✗ [skills + docs (@objectstack/spec)] examples do not compile:
  content/docs/api/declarative-endpoints.mdx:75:7      error TS2322: Type 'number' is not assignable to type 'undefined'.
  content/docs/getting-started/quick-reference.mdx:329:3   error TS2322: Type 'number' is not assignable to type 'undefined'.
  content/docs/protocol/kernel/http-protocol.mdx:1202:7   error TS2322: Type 'number' is not assignable to type 'undefined'.

That is the tombstone's tsc channel firing on documentation. check:skill-examples was red and lap 1 did not run it — it sits in check: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

class count action
cacheTtl — the ApiEndpoint key 14 occurrences / 13 lines cacheTtlSeconds
retryAfter — the ADR-0112 envelope field 14 occurrences retryAfterSeconds
HTTP Retry-After response header 6 locals over 4 sites unchanged
retry_after as a RetryStrategy enum value 4 unchanged
details.retry_after on the wire 5 unchanged
details.retryAfterSeconds (http-protocol.mdx:1126) 1 unchanged — see below

Each exclusion was verified rather than assumed. retry_after is a real enum member (errors.zod.tsz.enum(['no_retry', 'retry_immediate', 'retry_backoff', 'retry_after'])). The details.retryAfterSeconds at http-protocol.mdx:1126 is not something to align this rename to: it is a details member, it was already suffixed before this PR, and it is what the runtime actually emits (endpoint-policy.tsdetails: { retryAfterSeconds: retryAfterSec, … }). Counts confirm the exclusions held: Retry-After 12 → 12, retry_after 9 → 9.

content/docs/releases/0 files changed, verified on the final head.

check:doc-authoring: this PR now adds zero findings

Running the docs family surfaced a second red. check:doc-authoring forbids 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:

tree findings exit
base c1949b9ac (card 1/6's head) 4 1
this PR before the fix 16 1
this PR now 4 1

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 retiredKey guidance feeds .describe() and therefore the reference pages: check:generated exit 0 (all 15 current, gen:docs re-run), pnpm --filter @objectstack/spec test 473 files / 12760 tests, all passing, check:duration-unit-keys unchanged at 36 with api/ 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-bytes all exit 0, and check:doc-formula-expressions / check:doc-security-posture exit 0 under their real invocation (pnpm --filter @objectstack/lint run … — as plain pnpm 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 c1949b9ac960580850 (card 1/6's follow-up stripping the four #14478 citations 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-tree on this repo can return a false clean through the os-regen driver:

git -c merge.os-regen.driver=false merge-tree --write-tree --name-only 960580850 dc3b8471d8
→ exit 1   CONFLICT (content): content/docs/references/api/websocket.mdx
           Auto-merging packages/spec/src/api/websocket.zod.ts   ← clean

Locally the driver behaves differently and that difference is the hazard: git merge exited 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.

before regen after regen
(#14478 ruling B) occurrences (card 1/6's side) 2 0
reconnectIntervalMs / pingIntervalMs / heartbeatIntervalMs (my side) 6 6

Both sides present. ⛔ Not hand-edited, ⛔ not resolved by taking a side: the bytes come from check:generated --fix on the merged tree, run after the merge was committed — never in MERGE state, where gen:schema silently rolls the authorable-surface anchor back to the old fork point. The staged diff was read before committing (plain git diff reads clean over exactly this trap) and contains only card 1/6's two prescriptions returning. The os-regen pre-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 b4633903b

check result
check:duration-unit-keys exit 1 — 36 offenders among 215, api/ at 0, same exemption header (6 EpochMs, 11 externalVocabulary). Unchanged by the merge
check:generated exit 0, all 15 artifacts current
check:doc-authoring exit 0, 0 findings — was 4 on the old base; the merge brings in the fix for exactly those 4
pnpm --filter @objectstack/spec test exit 0 — 473 files, 12760 tests passing
pnpm --filter @objectstack/spec typecheck exit 0
check:skill-examples exit 0 — 257 prose examples type-check across 3 surfaces
check:nul-bytes, check:adr-0087-registration, check:empty-changeset, check:docs-audit-scope, check:doc-anchors, check:docs-single-h1, affected-docs all exit 0
content/docs/releases/ 0 files changed

And the conflict is gone at its source: git -c merge.os-regen.driver=false merge-tree --write-tree --name-only 960580850 HEADexit 0. git merge-base --is-ancestor 960580850 HEAD → exit 0, which is the self-proving direction on any checkout.

⚠️ GitHub reported mergeable_state: clean throughout — 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, and content/docs/releases/.


Generated by Claude Code

…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
…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
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 5, 2026

os-sales commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

CI red — root-caused. It is this PR's, one cause trips two pins

Flag docs affected by code changes failed on 8cd4d8cbac at 2026-09-05T10:22Z (node scripts/docs-audit/check-affected-docs.mjs, exit 1):

✗ self-test "the connector-action input is NOT admitted as a route source":
    packages/spec/src/conversions/registry.ts → expected scanRouteSurface=false, got true
✗ self-test "every contract declaration admitted is a packages/spec API declaration":
    kind=contract → expected scanRouteSurface=true, got false
✗ affected-docs self-test failed (2 case(s)).

⚠️ Note what this is not: it is not a docs-drift finding. It is the script's own --self-test, and both cases are live pins over the real tree, not fixtures.

The mechanism

scanRouteSurface() in scripts/docs-audit/affected-docs.mjs admits a file as kind: 'contract' when it is neither a ledger nor a call-site file, its raw text includes('path'), and parseRouteSource(text, { requireMethodSignal: true }) yields at least one tail.

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',}

path plus a literal method signal. packages/spec/src/conversions/registry.ts has always carried path:-shaped tails — the self-test's own counterfactual case asserts exactly that — and the only thing keeping it out of kind (b) was the absence of a method signal. This is the first conversion fixture to carry one, so the file is now admitted, which fails pin (1) by name and pin (2) because the path does not start with packages/spec/src/api/.

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 conversions/registry.ts is in this diff.

Disposition

Not by weakening the fixture. Dropping method would make the fixture unfaithful to what the conversion documents — its own summary says the key "stays GET-only" — and would only defer the failure to the next conversion fixture that carries a method.

Not by weakening either pin. They are doing precisely what their comments say they exist for: "the day a ledger starts declaring a path: route it reds here instead of losing the tail in silence." Today is that day, one file over.

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 packages/spec/src/conversions/**, and the pin's rationale says the guard is declared rather than accidental.

Sent to the card's dev with that reasoning, and with the question left genuinely open rather than pre-decided: scripts/docs-audit/** is gate-class and normally domain:devx, so if the dev reads this as belonging in a separate card it says so and pushes no scanner change — in which case the seat files that card and the stack carries a known-red advisory gate until it lands. The seat's own reading is that it belongs here, because this PR is what broke the pin and splitting it ships a red gate in between.

⛔ 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

os-sales commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Seat verification — independently reproduced, and it holds on every axis

Not a re-reading of the PR body: a separate pnpm install --frozen-lockfile + gate run in a dedicated detached worktree at 8cd4d8cbac, exit code captured before any pipe. The gate's own 37-case self-test passed first, so the instrument is sound on this tree.

✗ 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, by design. api/ = 0, whole tree = 36. Both claims confirmed.

⭐ The 12 left by rename, not by a new exemption

The exemption header is identical to the base's — 6 declared EpochMs instant(s), 11 declared externalVocabulary mirror(s) — and the census denominator is unchanged at 215. So the twelve keys are still in the population and now compliant, rather than having been quietly declared exempt. That is the difference between clearing a gate and hiding from it, and this PR did the former.

⭐ It did not move a population it does not own

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

dir expected measured
kernel/ 14 14
system/ 15 15
data/ 3 3
integration/ 2 2
ui/ 1 1
ai/ 1 1
shared/ 0 0

Nothing differs. Every non-api/ population is exactly the base's value; the only delta in the whole tree is api/ 12 → 0. Corroborated structurally: of the 64 changed files, zero are schema files under src/kernel/, src/system/, src/data/, src/ui/, src/ai/, src/integration/ or src/shared/. The single src/kernel/ path in the diff is a test file, not a schema.

The twelve renames, checked individually

All twelve old spellings survive only as retiredKey() tombstones — never as a live key — and all twelve new spellings are present. Widened beyond the named files: for each of the eight distinct old names, a property-line grep across every packages/spec/src/api/*.zod.ts, filtered of retiredKey(, returns nothing. The new spellings are also in real use at the built-in endpoint definitions in plugin-rest-api.zod.ts, so the rename reached the call sites and not just the declarations.

Governance and the Prime Directive

check-governed-merges.mjs --test over the 64-path list → exit 3, exactly one hit: skills/objectstack-api/SKILL.md. Its own verdict, quoted: "a human merge is the review record for this PR (#9495 regime). No seat flips it ready, enqueues it, or arms auto-merge. One hit governs the whole PR."

content/docs/releases/ changed files: 0. The eight content/docs/ paths in this diff are all regenerated pages under content/docs/references/api/. Directive satisfied.

What remains

Only the Flag docs affected by code changes red, root-caused in the comment above and now with the dev. Nothing in this verification touches it — the offender counts, dispositions and tombstones are unaffected by that scanner question either way.

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

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/runtime, @objectstack/spec, touching 40 documentable anchor(s). ⚠️ 18 changed file(s) yielded no anchor (packages/runtime/src/endpoint-executor.ts, packages/spec/REST_API_PLUGIN.md, packages/spec/authorable-defaults/api.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/client-sdk.mdx (via getFieldLabels (sdk, the bare tail of client method i18n.getFieldLabels, bound to GET /api/v1/i18n/labels/:object/:locale; the bare tail of client method i18n.getFieldLabels, bound to GET /i18n/labels/:object/:locale), getTranslations (sdk, the bare tail of client method i18n.getTranslations, bound to GET /api/v1/i18n/translations/:locale; the bare tail of client method i18n.getTranslations, bound to GET /i18n/translations/:locale), i18n.getFieldLabels (sdk, the route ledger binds it to GET /api/v1/i18n/labels/:object/:locale, selected by route anchor /labels/:object/:locale; the route ledger binds it to GET /i18n/labels/:object/:locale, selected by route anchor /labels/:object/:locale), i18n.getTranslations (sdk, the route ledger binds it to GET /api/v1/i18n/translations/:locale, selected by route anchor /translations/:locale; the route ledger binds it to GET /i18n/translations/:locale, selected by route anchor /translations/:locale))
  • content/docs/api/declarative-endpoints.mdx (via ApiEndpointSchema (symbol, a top-level const object), cacheTtlSeconds (symbol, a field of const object ApiEndpointSchema, an authorable key of api/ApiEndpoint), cacheTtlSeconds (literal, a string literal in ApiEndpointSchema; a string literal in EndpointGateIssue; a string literal in apply; a string literal in computeCacheControl; a string literal in policyGate; a string literal in summary), object_operation (literal, a string literal in fixture))
  • content/docs/api/error-catalog.mdx (via retryAfterSeconds (literal, a string literal on a changed line))
  • content/docs/api/error-handling-client.mdx (via retryAfterSeconds (literal, a string literal on a changed line))
  • content/docs/api/plugin-endpoints.mdx (via /labels/:object/:locale (route, bridged from symbol cacheTtlSeconds — its route source's handler names it), /translations/:locale (route, bridged from symbol cacheTtlSeconds — its route source's handler names it))
  • content/docs/automation/flows.mdx (via create_task (literal, a string literal in fixture))
  • content/docs/getting-started/quick-reference.mdx (via cacheTtlSeconds (symbol, a field of const object ApiEndpointSchema, an authorable key of api/ApiEndpoint), cacheTtlSeconds (literal, a string literal in ApiEndpointSchema; a string literal in EndpointGateIssue; a string literal in apply; a string literal in computeCacheControl; a string literal in policyGate; a string literal in summary), object_operation (literal, a string literal in fixture))
  • content/docs/kernel/services-checklist.mdx (via getFieldLabels (sdk, the bare tail of client method i18n.getFieldLabels, bound to GET /api/v1/i18n/labels/:object/:locale; the bare tail of client method i18n.getFieldLabels, bound to GET /i18n/labels/:object/:locale), getTranslations (sdk, the bare tail of client method i18n.getTranslations, bound to GET /api/v1/i18n/translations/:locale; the bare tail of client method i18n.getTranslations, bound to GET /i18n/translations/:locale), /labels/:object/:locale (route, bridged from symbol cacheTtlSeconds — its route source's handler names it), /translations/:locale (route, bridged from symbol cacheTtlSeconds — its route source's handler names it))
  • content/docs/protocol/kernel/error-handling.mdx (via retryAfter (literal, a string literal on a changed line))
  • content/docs/protocol/kernel/http-protocol.mdx (via ApiEndpointSchema (symbol, a top-level const object), cacheTtlSeconds (symbol, a field of const object ApiEndpointSchema, an authorable key of api/ApiEndpoint), cacheTtlSeconds (literal, a string literal in ApiEndpointSchema; a string literal in EndpointGateIssue; a string literal in apply; a string literal in computeCacheControl; a string literal in policyGate; a string literal in summary), object_operation (literal, a string literal in fixture), retryAfter (literal, a string literal on a changed line), retryAfterSeconds (literal, a string literal on a changed line))
  • content/docs/protocol/kernel/i18n-standard.mdx (via getTranslations (sdk, the bare tail of client method i18n.getTranslations, bound to GET /api/v1/i18n/translations/:locale; the bare tail of client method i18n.getTranslations, bound to GET /i18n/translations/:locale), /translations/:locale (route, bridged from symbol cacheTtlSeconds — its route source's handler names it))
  • content/docs/ui/translations.mdx (via /translations/:locale (route, bridged from symbol cacheTtlSeconds — its route source's handler names it))

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

  • content/docs/releases/v17.mdx (via EnhancedApiErrorSchema (symbol, a top-level const), /labels/:object/:locale (route, bridged from symbol cacheTtlSeconds — its route source's handler names it))

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
  • 18 changed file(s) yielded no anchor (packages/runtime/src/endpoint-executor.ts, packages/spec/REST_API_PLUGIN.md, packages/spec/authorable-defaults/api.json, …) — pages documenting those are invisible to this run
  • 10 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 135 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 c09fd102342111ac4949f9d9a8d797c12094669epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 1a066680b270fb6247df33a02075cd65e1f988bb — the merge of head dcd1be0dba5768b50b15effe05c17bbd383c891d into base c09fd102342111ac4949f9d9a8d797c12094669e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older claude/issue-14478-duration-unit-in-key-name 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 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

⚠️ 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 c09fd102342111ac4949f9d9a8d797c12094669e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-sales commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Second finding — the hand-written docs still teach the old spellings

Measured on the new head d7ebd6c7ad at 2026-09-05T10:4xZ, after the push.

First, what is intact: content/docs/releases/ changed files = 0, and the only content/docs paths in the diff are the 7 regenerated reference pages under content/docs/references/api/.

That last fact is the gap. This card's execution note requires "liveness ledgers, forms, i18n bundles, docs and skills/** prose updated where they name the key". The generated reference pages moved. The hand-written prose did not.

cacheTtl — 13 occurrences, unambiguously the renamed authorable key

git grep over content/docs, excluding references/ and releases/, filtered of cacheTtlSeconds:

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-After response header ⇒ leave alone. protocol/kernel/error-handling.mdx:450-451 and protocol/kernel/http-protocol.mdx:1319, both response.headers.get('Retry-After'). Renaming these is precisely the mistake the PR warns the next reader against.
  • retry_after snake_case wire detail ⇒ a third surface. protocol/kernel/error-handling.mdx:734, and api/error-catalog.mdx:47 names 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

os-sales commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Seat review of both fixes — verified on dc3b8471d8, and one cross-lane decision declared

All 11 checks green, including Flag docs affected by code changes.

1. The scanner fix took the route I asked for, and improved on it

I forbade one of the two available routes, so the accountable check is which one was taken. Measured: between 8cd4d8cba and d7ebd6c7a exactly one file changed — scripts/docs-audit/affected-docs.mjs — and the conversion fixture still carries list_tasks / create_task with their method: keys. ⛔ The fixture was not weakened, and neither live pin was weakened, retargeted or deleted.

The new guard is structural and narrow:

const CONVERSION_REPLAY_FILE_RE = /(?:^|\/)packages\/spec\/src\/conversions\//;

Checked what it actually excludes: 11 files under that directory, with zero app.get / router.get / createRoute hits. The 14 handler: occurrences are all fixture payloads — cron jobs, hooks, connectors. So the guard removes replay data and no real route source.

Two things in it are better than what I proposed:

  • It added the pin that protects the guard. The pre-existing counterfactual case passes under the old method proxy too, so it would have kept passing if someone later deleted the directory guard as redundant. The new case — "the METHOD proxy alone no longer excludes it — the directory guard is load-bearing" — is the one that would red. That gap is easy to miss and it was found without being pointed at.
  • It rejected the tidier alternative for the right reason. Restricting kind (b) to packages/spec/src/api/** would make the pin "every contract declaration admitted is a packages/spec API declaration" true by construction — a check that cannot fail, over the population most likely to widen by accident. Its own words: "the pin is worth more than the tidier rule." Correct, and it is the reasoning I wanted rather than an echo of mine.

2. ⚠️ Cross-lane rider — declared here rather than left silent

scripts/docs-audit/** is gate-class and normally domain:devx; this is a domain:spec PR. The seat judged the fix belongs here because this PR is what broke the pin, and splitting it would land a red advisory gate in between. The dev was given the question genuinely open — with an explicit instruction to push no scanner change if it read the scope differently — and independently reached the same conclusion.

Recording it because an execution seat crossing into another lane's files without a note is exactly the kind of half-state the protocol forbids. ⛔ No domain:* label was changed by this; routing remains triage's.

3. The docs classification is correct on every trap

cacheTtl bare in hand-written docs: 13 → 0.
retryAfter bare: 19 → 6, and all six survivors are right:

protocol/kernel/error-handling.mdx:450   const retryAfter = response.headers.get('Retry-After')
protocol/kernel/error-handling.mdx:451   await sleep(retryAfter * 1000)
protocol/kernel/error-handling.mdx:734   const retryAfter = data.error.details.retry_after || 1
protocol/kernel/error-handling.mdx:735   await sleep(retryAfter * 1000)
protocol/kernel/http-protocol.mdx:1319   const retryAfter = Number(response.headers.get('Retry-After') ?? 1)
protocol/kernel/http-protocol.mdx:1320   await sleep(retryAfter * 1000)

Every one is a local variable name in example code reading a different surface — the RFC 9110 header or the snake_case detail. Renaming them would have been a regression, and that is the mistake this PR's tombstone prose, semantic entry and dedicated test all exist to prevent. retry_after is intact as a wire key everywhere it appears.

⭐ The sharpest single line is api/error-catalog.mdx:47, which carried two different things: it now reads | retry_after | Wait the retryAfterSeconds the envelope carries | — the snake_case error code kept, the envelope field renamed. Per-occurrence judgement, not a sweep.

content/docs/releases/ changed files: 0. Five hand-written pages plus the seven regenerated reference pages.

4. The verified gate reading still stands on this head

⚠️ Stating my own correction: at path level, seven packages/spec/src/api/*.zod.ts files changed after the head I verified, which looked like it required a re-run. At line level it does not. All 42 changed lines in those files are retiredKey() message strings (the strip-issue-ids convention); zero touch z.number / z.int / z.coerce, .describe(, .meta(, or a key declaration. The gate reads keys, describes and markers, so its population and verdict cannot move on prose inside a tombstone message.

36 offenders, api/ at 0, every non-api/ population bit-for-bit unchanged — verified by independent enumeration on 8cd4d8cba and unaffected by everything since.

⛔ Still a draft, still not enqueued, no auto-merge, not approved. Governed on skills/objectstack-api/SKILL.md; human merge.


Generated by Claude Code

…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
@os-zhuang
os-zhuang marked this pull request as ready for review September 5, 2026 15:52
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants