feat(spec)!: duration-shaped number keys carry their unit in the key name — no-baseline gate + seven ADR-0087 renames (timeoutMs, ttlSeconds/ttlMs, *TimeoutSeconds) - #15626
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…#14478) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…4478) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…on, regen docs (#14478) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
📓 Docs Drift CheckThis PR changes 6 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 136 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 849565670761f6dc23342a33ce614fc1a1959759 && git checkout 849565670761f6dc23342a33ce614fc1a1959759
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d30ccb9bd963942d33072443f680a32664087346 e68ae2b5822f7358f181df6c54bbd94f2e9d9ad3 && git checkout -B drift-repro d30ccb9bd963942d33072443f680a32664087346 && git merge --no-ff e68ae2b5822f7358f181df6c54bbd94f2e9d9ad3
node scripts/docs-audit/affected-docs.mjs --json d30ccb9bd963942d33072443f680a32664087346
|
`turso-driver-options-door.test.ts` builds a `Parameters<TursoDriver['update']>[3]` literal — that argument IS `DriverOptions`, so the `timeout` key it wrote is the one renamed to `timeoutMs` here, not a driver-local key. Same magnitude (milliseconds), no value conversion. `TursoDriverConfig.timeout` in `turso-driver.ts` and the `timeout` in `src/spec/turso.zod.ts` are a different key on the driver's own connection schema and stay as they are. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
Director audit note — both "blockers" relayed to the maintainer on this PR are already settled on the record; one measured fact added on mergeabilityDirector seat (objectstack #12708), summon #14, session 1. The population boundary is ruled, not open. Batch #43 ruled B on #14478 at 2. Mergeability — measured now, not relayed. Fetched One conflict, in a generated liveness-count file; the eight other touched files ( 3. Whose trip it is. Per the ruling's execution rule (2) and the seat's own comment, this PR is driven by Generated by Claude Code |
Part of #14478
Fixes #14519
Executes the maintainer ruling recorded on #14478 at comment
5518649320— ruled B, verbatim 「14461 你不处理,其他同意」, on the standing rules 「不考虑存量」 and 「项目在创业阶段,用户也很少,短期不考虑渐进。」: a spec-source gate for duration-shapedz.number()keys with no grandfathered baseline, plus an ADR-0087 conversion of every offender the ruling named, in one PR. Dispatched by thedomain:specseat (claim5547289696),CONTRACT_REVIEW_TIER.Clause-②: yes — seven published authorable keys are renamed.
needs:contract-reviewis carried on the card and on this PR; the isolated contract-tier review is the seat's to dispatch, and the gate stays on both carriers until it clears.⛔ Landing: this PR is GOVERNED — draft is its finished state, a human merges it
Draft is not "unfinished" here. One path in this diff is on the governed-surface register, and one hit governs the whole PR — 「混合 diff 一条命中即整 PR 分叉」 (maintainer, 2026-08-18; AGENTS.md Prime Directive #14). Measured, not assumed:
node scripts/pm/check-governed-merges.mjs --test skills/objectstack-data/references/data-hooks.md packages/spec/src/data/hook.zod.ts→ exit 3,skills/** ×1 — the published skills catalog: skills/objectstack-data/references/data-hooks.md, with the spec path listed as not on the register; the control leg (--test packages/spec/src/data/hook.zod.tsalone) exits 0. The other 62 files are ordinary.The
skills/**edit is kept on purpose and must not be dropped to escape governance: onorigin/mainthat reference namestimeoutat:109-110(an authored example,timeout: 5000,) and in the prose lists at:871and:885; landing the rename without it would have the published skills catalog teach a spelling the schema now refuses — a defect strictly worse than a human merge. So: ⛔ no seat flips this PR ready, enqueues it, arms auto-merge, or approves it (an agent-operated approver account counts as a seat). Review is requested fromhotlong(a governed approver); the human merge is the review record. Nothing about the change itself is in question — this section is about how it lands.The ruling was adopted on a measured radius of five keys (plus the two #14519 keys), three readers and one in-repo author. The card's own inventory said that of 178 unit-naming describes "for most of them the unit is also in the key name". That premise is false by an order of magnitude. Deriving the offender set mechanically from
packages/spec/src/**with the ruling's own rule — az.number()chain whose.describe()names a time unit and whose key name carries none — finds 70 offenders onca46f8f12(six more were detector false positives, since removed: ordinal "second",minas minimum). The ruling's seven are among them. After this PR converts those seven, 65 remain, in four classes (full list in the gate's own output,pnpm --filter @objectstack/spec check:duration-unit-keys):RestApiEndpoint.timeout/cacheTtl,WebSocketConfig.pingInterval/heartbeatInterval/timeout,CollaborationSessionConfig.idleTimeout(ms — the same name the tenant key had in seconds),EventQueueConfig.retention(days),RegistryConfig.syncInterval/ttl,DashboardConfig.refreshIntervalHttpCacheConfig.maxAge/staleWhileRevalidate/staleIfError(Cache-Control directives), CORSmaxAgetwice, better-authexpiresInthree times, S3 presignexpiresIn, pgstatementTimeout, DNSttl, OAuth device-flowintervalStartupResult.duration/totalDuration,PluginHealth.uptime/responseTime,TraceSpan.duration,ApiError.retryAfter(the wire envelope)timestamp,lastSeen,startTime,registeredAt,createdAtI stopped at the ruling's seven and did not convert the other 65. Converting them is ten times the radius the maintainer measured when adopting B, includes keys whose spelling is fixed by HTTP, OAuth, AWS, DNS and better-auth, and includes runtime-emitted shapes for which an ADR-0087 conversion has no seam — decisions the ruling did not make. Under the four-axis frame the rule stands; what is undecided is the population: literal (convert all 65), or the rule minus instants and external-vocabulary mirrors (declared in-schema, never as a gate ledger), or the rule restricted to the authorable metadata-type surface. That is put to the maintainer in the report as
needs_decision.Consequence: this PR is red on its own gate, by design. The gate is wired into
lint.ymlas the last step of theLint & Repo Gatesjob so it masks nothing behind it, and it prints the 65 remaining offenders. ⛔ It has no baseline and no exception list — the dispatch forbade a gate that passes only because its exceptions were enumerated. It goes green the day the remainder is converted or the population is narrowed by decision.What this PR does
1. The gate —
packages/spec/scripts/check-duration-unit-keys.tspnpm --filter @objectstack/spec check:duration-unit-keys(self-test first, wired ascheck:self-test-wiredrequires; classifiedNO_GENERATORincheck-generated.ts; declared populationpackages/spec/src/**via theROOT_DIR_WATCH_HINTSidiom, held against the scan root in the self-test).The rule, one direction each way: a property whose value is a
z.number()/z.int()/z.coerce.number()chain and whose.describe()names a time unit must carry that unit as a token of its key name (Ms/Seconds/Minutes/Hours/Days, plus the knex-inheritedMillis), and the token must agree with the prose —ttlMsdescribed "in seconds" is refused too.{ value, unit }pairs are recognised structurally by the siblingunitkey; duration literals ('14d') are strings and outside the population. Calendar positions ("day of the month (1-31)") and rates ("requests per second") are skipped. Singular prose forms count only with a number in front ("1 second"), which is what keeps the ordinal "second pass" andminas minimum out.Why
packages/spec/scripts/and notpackages/lint:@objectstack/lintvalidates a customer's metadata graph at build time — pure(stack) => Issue[]functions the CLI and AI authoring share. This gate reads this package's own source and judges how a schema is declared; it has no stack to validate and nothing a customer could run it on. That is the shape of every other spec source audit (check-exported-any,check-dual-source-exports,check-error-code-provenance).Why the name-only rule is a census row and not a verdict: judged by name alone ("a key called
sessionTimeoutwith no unit anywhere") the rule fired 44 times onca46f8f12, and most were counts wearing a duration's vocabulary —contextWindow,slidingWindowSize,snapshotInterval("every N events"),reflectionInterval("every N interactions"),backoffMultiplier,staleKeys. A rule that cannot tell a window of tokens from a window of seconds would either grandfather those by name or teach authors to appendMsto a count.--liststill prints the ~25 genuine unit-nowhere keys (the #14519 shape:logging.flushInterval,tracing.exportTimeout,tenant.schemaCacheTTL,plugin-lifecycle-advanced.shutdownTimeout, …) so the population stays visible.2. The seven conversions — one ADR-0087 entry each, ⛔ no alias, no transition window
HookSchema(hooks[])timeout→timeoutMsretiredKey()tombstone on the strict shape (carries the rename;tscnever+ parse); aliastimeoutms → timeoutremovedhook-timeout-to-timeout-ms(retired from the load path) + step 18JobSchema(jobs[])timeout→timeoutMstimeoutMs → timeoutremoved;system/Job:timeoutregisteredjob-timeout-to-timeout-ms+ step 18DriverOptionsSchematimeout→timeoutMsdata/DriverOptions:timeoutregistereddriver-options-timeout-to-timeout-ms(a per-call options object has no stack seam)MetadataManagerConfigSchemacache.ttl→cache.ttlSeconds;cache.databaseLoader.ttl→cache.databaseLoader.ttlMsmetadata-manager-config-cache-ttl-unit-in-keyDatabaseLevelIsolationStrategySchema/TenantSecurityPolicySchemaconnectionPool.idleTimeout→idleTimeoutSeconds;accessControl.sessionTimeout→sessionTimeoutSeconds; describes now say "in seconds"tenant-timeouts-unit-in-keyEvery old spelling is refused with a prescription naming the new key (pinned per schema in
hook.test.ts,job.test.ts,driver.test.ts,metadata-loader.test.ts,tenant.test.ts, each with atsc-channel case).Hookis not on the authorable surface (itshandleris a function), so it has noRETIRED_KEYS_BY_MAJORrow; the two nestedttlkeys and the tenant keys are not surface rows either.#14519 is genuinely completed and carried as
Fixes: both tenant keys carry their unit in the name and their.describe()now says "in seconds" — pinned, because.describe()is whatcontent/docs/references/**publishes and the JSDoc above a key is not, so the reference-page reader was the one reader who never saw the unit. #14519's own proposed fix (add the unit to the describe only) is exactly what the new gate refuses, which is why the keys were renamed instead.3. Readers, in the same PR
packages/metadata/src/loaders/database-loader.ts(:136,:252)DatabaseLoaderCacheOptions.ttl→ttlMs;ttl: cacheOpts?.ttlMs ?? 60_000(same magnitude)packages/objectql/src/hook-wrappers.ts:358meta.timeout→meta.timeoutMspackages/spec/src/contracts/job-service.tsJobScheduleOptions.timeout→timeoutMs— renamed in lockstep: a contract key that re-spelled the value without its unit would reintroduce one layer down exactly the ambiguity the rename removedpackages/runtime/src/app-plugin.ts:1098{ retryPolicy, timeoutMs: job.timeoutMs }packages/services/service-job/src/run-with-policy.ts:127,db-job-adapter.tsoptions?.timeoutMs;withoutPolicystripstimeoutMsexamples/app-showcase/src/automation/jobs/index.ts:23timeoutMs: 300000packages/spec/src/data/hook.form.ts:71timeoutMs(i18n bundles regenerated withnode scripts/check-i18n-bundles.mjs --write)hook.json/job.jsontimeoutMslive rows with the same anchors;timeoutrows kept asdeadtombstone rows (theretiredKeyroute keeps the key in the walked shape)content/docs/automation/jobs.mdx,content/docs/protocol/kernel/metadata-service.mdx,packages/metadata/README.md:180skills/objectstack-data/references/data-hooks.md(governed)timeoutMsin the example and the two prose listsThe README candidate is in scope, and was changed: it sits in the package whose reader is renamed here, it demonstrates the exact spelling the schema now refuses, and
check:skill-examples-style example rot is the failure this rule exists for. Cost one line. The bare-key sweep was not done:playwright.config.ts,sqlite-occupancy.ts,serve-process.ts, the compose file, SMTP transporttimeout,RegistryConfig.cache.ttl(its own schema, in the remainder) are different keys on different schemas and were left alone — every hit was disambiguated to its declaring schema first.Seat coordinates re-measured and confirmed:
database-loader.ts:252(not:204),hook-wrappers.ts:358(not:357),run-with-policy.ts:127,jobs/index.ts:23. One seat assertion the tree contradicts: the card says the two tenant keys are "on the authorable surface";authorable-surface/system.jsoncarries noTenant*row at all (the only tenant rows arecloud/ProvisionTenantRequest:*), so noRETIRED_KEYS_BY_MAJORentry exists for them and none is owed.4. Changesets — level derived from the repo's rule, not from the dispatch
scripts/check-changeset-no-major.mjs(header: breaking changes ship asminorduring the launch window; the BREAKING banner and the ADR-0087 disposition are the carriers) +pr-automation.yml"WHICH LEVEL" + precedent inpackages/spec/CHANGELOG.md(**BREAKING** … shipped as minor):@objectstack/specminor, BREAKING banner,adr-0087: registerednaming the five ids;@objectstack/metadataminor, BREAKING (DatabaseLoaderOptions.cache.ttl→ttlMsis an exported interface member),adr-0087: registered metadata-manager-config-cache-ttl-unit-in-key(the gate refusedalready-registeredfor an id this diff adds — measured, corrected);@objectstack/objectql,@objectstack/service-job,@objectstack/runtimepatch — they read the renamed key; no public surface of their own moves.node scripts/check-adr-0087-registration.mjs --base origin/main→✓ check-adr-0087-registration: 2 declared-breaking changeset(s), each carrying an ADR-0087 disposition.5. Skills line readings (the
skills/**diff)skills/objectstack-data/references/data-hooks.md: 979 → 979 lines. Whole packageskills/objectstack-data/**/*.md: 3736 → 3736. Allskills/**/SKILL.md: 6835 → 6835. Net zero; no re-wrap, no content bought.Out-of-scope finding, filed (not ridden along)
MetadataManagerConfig.cache.{enabled, ttlSeconds, maxSize}are declared and documented but read by nothing — onlycache.databaseLoaderreaches a runtime #15624 —MetadataManagerConfig.cache.{enabled, ttlSeconds, maxSize}are declared and published but read by nothing; only the nestedcache.databaseLoaderblock reaches a runtime (metadata-manager.ts:618,:648). Surfaced by tracing the readers ofcache.ttlfor this rename; the ruling renames the key and does not ask whether it is read, so the ADR-0049 question is filed rather than answered here.Verification (final head
99999540a)pnpm --filter @objectstack/spec build→VERDICT command-exit 0(161s, then 196s after the tombstone text edit);check:generated --fix→✓on 14 of 15, the 15th (check:react-declaration-parity) needs objectui's manifest and cannot run here.pnpm --filter @objectstack/spec typecheck(src + scripts + test-typecheck) →VERDICT command-exit 0.pnpm --filter @objectstack/metadata --filter @objectstack/objectql --filter @objectstack/service-job --filter @objectstack/runtime typecheck→VERDICT command-exit 0.hook,job,driver,tenant,metadata-loader,conversions/,migrations/,alias-integrity,retired-key-migrate-sentence,strict-object,hook-body,hook-form,check-generated-ledger→Test Files 16 passed,Tests 719 passed(after the last three job fixtures were renamed).database-loader.test.ts86 passed; objectqlhook-binder+hook-metrics32 passed; service-jobdb-job-adapter.timeout+cron-job-adapter+interval-job-adapter40 passed.check:liveness→✓ every governed-type property … is classified(hook 22 classified, live 19 dead 3; job 16, live 15 dead 1);check:i18n→ 0 after regeneration;check:skill-examples→ 0 (needsclient-reactbuilt first — measured: a stale-dist refusal until then).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(147 commands; exit codes captured before any pipe): all green on the final head except —check:duration-unit-keys1 (the 65 remaining offenders, by design);check:dual-build-cjs-loads3 PREREQUISITE NOT MET (needs a fullpnpm build; NOT MEASURED);check-engine-split-ratio --days 902 (shallow clone refusal; NOT MEASURED);check-required-contexts --verify-required-set2 in the farm (HTTP 401 without the proxy) and 0 re-run asNODE_OPTIONS=--use-env-proxy;check:pm-dispatch-gatesrun detached per its own header →✓ dispatch-gates self-test: 1445 cases pass.(exit code not captured — detached).check:docs-audit-scopered once mid-farm on an earlier head (self-test case "every contract declaration admitted is a packages/spec API declaration") and green on every re-run, including the pristine base worktree. Not new and not this PR's:check:skill-examplesleavespackages/spec/.examples-build/behind, andcheck:docs-audit-scopethen fails its own self-test in the same working tree #15446 records exactly this sequence —check:skill-examplesleavespackages/spec/.examples-build/behind and the docs-audit self-test then admits it — and the farm had runcheck:skill-examplesimmediately before. Duplicate-searched before concluding (control query answered spec: duration-shaped number keys carry their unit in describe prose only — twottlkeys with different units in one block, baretimeoutkeys, unit-less tenant timeouts #14478).Ablation — the gate measures something, and the refusals fire
Script kept in the session scratchpad; every leg confirmed on disk before its reading, restored with a trap and proved by blob hash (
5d7306265c5c…before and after) plusgit diff HEADempty:src/zz-ablation-offender.zod.ts,cooldown"in seconds"): 65 → 66 offenders, the injected site named; file removed → 65.--rooton scratch trees: a tree of three compliant keys (ttlMs,retentionDays, a{ value, unit }pair) → exit 0, "zero offenders"; the same tree plus onettl"in seconds" → exit 1, 1 offender.grep -c1 on the marker, 0 on the original anchor): self-test 7 cases red, production count 65 → 2 — the self-test is the instrument watching the rule, ascheck-self-test-wiredrequires.conversions.test.ts/migrations.test.ts.Fix lap — workspace type check restored (head
e68ae2b5)One file changed since
99999540a:packages/drivers/driver-turso/src/turso-driver-options-door.test.ts,timeouttotimeoutMs. Scope was the type check and nothing else — the gate's 65 remaining offenders are untouched andLint & Repo Gatesstays red by design.DriverOptionsoutsidenode_modules(73 of them) was read for the retired spelling, and separately every baretimeout:/ttl:key literal and every.timeoutproperty read underpackages/**,apps/**andexamples/**. One site: the turso door test. The empty results are part of the reading — memory, mongodb, sql and sqlite-wasm each carry their own door / conformance tests and none writes the renamed key;NoSQLQueryOptionsSchema.timeout,DataEngineExecuteRequest.options(a free-formz.record),LRUCache.ttl,lifecycle.ttl, the SMTP transporttimeoutandHealthCheckConfig.timeoutare all other schemas, and the survivingttlreaders already spellttlMs/ttlSeconds.pnpm --filter @objectstack/driver-turso typecheckon99999540areproduced the two CI errors verbatim —VERDICT command-exit 2.pnpm --filter @objectstack/driver-turso test—VERDICT command-exit 0,Test Files 44 passed (44),Tests 1159 passed (1159).e68ae2b5:pnpm exec turbo run typecheck --concurrency=2 --filter='./packages/*' --filter='./packages/*/*' --filter='./apps/*'givesTasks: 135 successful, 135 total— 70 of themtypechecktasks, all five driver packages among them, zeroerror TS,VERDICT command-exit 0. That is the answer to "is a second driver hiding behind the first": turbo tore down 21 tasks after turso failed on99999540a, and here every one of them ran.*.test.ts.@objectstack/driver-tursopublishesfiles: ["dist","README.md","CHANGELOG.md"]built from asrc/index.tsentry, andcheck:published-filesre-confirms every publishable package "admits no test": the edit releases nothing, which is case 2 ofpr-automation.yml"WHICH LEVEL". No public surface of that package moves — the type that moved isDriverOptions, and@objectstack/specalready carries the BREAKING changeset for it. Theskip-changesetlabel is not the remedy either: that is for a PR releasing nothing at all, and this one releases plenty.e68ae2b5, each exit code captured by redirecting to a file before any pipe:check:nul-bytes0,check:cross-package-test-inputs0,check:test-source-alias0,check:type-source-resolution0,check:published-files0.check:duration-unit-keys1, printing✗ check:duration-unit-keys — 65 offender(s) among 215 duration-shaped numeric key(s) in 762 source file(s)— the same 65 as before this lap, so the held decision is untouched.packages/spec/src/**only, and the same offender shape exists outside it (measured:--root ../drivers/driver-turso/srcreports 1 offender,src/spec/turso.zod.ts:104). A second axis of the same open population question, so it is recorded rather than answered.🤖 Generated with Claude Code
https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
Generated by Claude Code
Generated by Claude Code