Skip to content

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

Merged
os-zhuang merged 15 commits into
mainfrom
claude/issue-14478-duration-unit-in-key-name
Sep 6, 2026
Merged

os-zhuang merged 15 commits into
mainfrom
claude/issue-14478-duration-unit-in-key-name

Conversation

@claude

@claude claude Bot commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

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-shaped z.number() keys with no grandfathered baseline, plus an ADR-0087 conversion of every offender the ruling named, in one PR. Dispatched by the domain:spec seat (claim 5547289696), CONTRACT_REVIEW_TIER.

Clause-②: yes — seven published authorable keys are renamed. needs:contract-review is 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.ts alone) exits 0. The other 62 files are ordinary.

The skills/** edit is kept on purpose and must not be dropped to escape governance: on origin/main that reference names timeout at :109-110 (an authored example, timeout: 5000,) and in the prose lists at :871 and :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 from hotlong (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.

⚠️ Read this first: the ruling's offender list is 7; the ruling's rule finds 70

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 — a z.number() chain whose .describe() names a time unit and whose key name carries none — finds 70 offenders on ca46f8f12 (six more were detector false positives, since removed: ordinal "second", min as 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):

class count examples
authored config durations — the ruling's class, unnamed by the ruling ~30 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.refreshInterval
keys whose spelling mirrors an external standard ~12 HttpCacheConfig.maxAge / staleWhileRevalidate / staleIfError (Cache-Control directives), CORS maxAge twice, better-auth expiresIn three times, S3 presign expiresIn, pg statementTimeout, DNS ttl, OAuth device-flow interval
runtime-emitted measurements, never authored ~16 StartupResult.duration / totalDuration, PluginHealth.uptime / responseTime, TraceSpan.duration, ApiError.retryAfter (the wire envelope)
instants — "Unix timestamp in milliseconds" 6 timestamp, lastSeen, startTime, registeredAt, createdAt

I 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.yml as the last step of the Lint & Repo Gates job 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.ts

pnpm --filter @objectstack/spec check:duration-unit-keys (self-test first, wired as check:self-test-wired requires; classified NO_GENERATOR in check-generated.ts; declared population packages/spec/src/** via the ROOT_DIR_WATCH_HINTS idiom, 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-inherited Millis), and the token must agree with the prose — ttlMs described "in seconds" is refused too. { value, unit } pairs are recognised structurally by the sibling unit key; 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" and min as minimum out.

Why packages/spec/scripts/ and not packages/lint: @objectstack/lint validates 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 sessionTimeout with no unit anywhere") the rule fired 44 times on ca46f8f12, 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 append Ms to a count. --list still 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

schema before → after route ADR-0087
HookSchema (hooks[]) timeout → timeoutMs retiredKey() tombstone on the strict shape (carries the rename; tsc never + parse); alias timeoutms → timeout removed D2 hook-timeout-to-timeout-ms (retired from the load path) + step 18
JobSchema (jobs[]) timeout → timeoutMs tombstone; alias timeoutMs → timeout removed; system/Job:timeout registered D2 job-timeout-to-timeout-ms + step 18
DriverOptionsSchema timeout → timeoutMs tombstone (non-strict); data/DriverOptions:timeout registered semantic driver-options-timeout-to-timeout-ms (a per-call options object has no stack seam)
MetadataManagerConfigSchema cache.ttl → cache.ttlSeconds; cache.databaseLoader.ttl → cache.databaseLoader.ttlMs two tombstones (non-strict nested objects) semantic metadata-manager-config-cache-ttl-unit-in-key
DatabaseLevelIsolationStrategySchema / TenantSecurityPolicySchema connectionPool.idleTimeout → idleTimeoutSeconds; accessControl.sessionTimeout → sessionTimeoutSeconds; describes now say "in seconds" two tombstones semantic tenant-timeouts-unit-in-key

Every 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 a tsc-channel case). Hook is not on the authorable surface (its handler is a function), so it has no RETIRED_KEYS_BY_MAJOR row; the two nested ttl keys 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 what content/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

reader before → after
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:358 meta.timeout → meta.timeoutMs
packages/spec/src/contracts/job-service.ts JobScheduleOptions.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 removed
packages/runtime/src/app-plugin.ts:1098 threads { retryPolicy, timeoutMs: job.timeoutMs }
packages/services/service-job/src/run-with-policy.ts:127, db-job-adapter.ts options?.timeoutMs; withoutPolicy strips timeoutMs
examples/app-showcase/src/automation/jobs/index.ts:23 timeoutMs: 300000
packages/spec/src/data/hook.form.ts:71 form field timeoutMs (i18n bundles regenerated with node scripts/check-i18n-bundles.mjs --write)
liveness ledgers hook.json / job.json timeoutMs live rows with the same anchors; timeout rows kept as dead tombstone rows (the retiredKey route keeps the key in the walked shape)
docs: content/docs/automation/jobs.mdx, content/docs/protocol/kernel/metadata-service.mdx, packages/metadata/README.md:180 new spellings
skills/objectstack-data/references/data-hooks.md (governed) timeoutMs in the example and the two prose lists

The 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 transport timeout, 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.

CORRECTION — fix lap, 2026-09-05, head e68ae2b5. The list above originally also named turso timeout among the "different keys on different schemas". That was wrong. It is corrected in place rather than deleted, because the mistake names its own failure mode: turso carries two timeout spellings and the disambiguation collapsed them into one.

  • TursoDriverConfig.timeout (packages/drivers/driver-turso/src/turso-driver.ts:108) and its zod twin packages/drivers/driver-turso/src/spec/turso.zod.ts:104 — the driver's OWN connection config. It lives outside packages/spec/src/**, which is this gate's entire declared population (ROOT_DIR_WATCH_HINTS, held by the script's self-test). Genuinely a different key on a different schema, and still untouched — correctly.
  • packages/drivers/driver-turso/src/turso-driver-options-door.test.ts:119 — a literal typed by the Parameters lookup on argument 3 of TursoDriver.update. That argument is DriverOptions, so the key written there was exactly the one this PR renames. Missed.

The miss was an UNDER-collection — the mirror image of the over-collection the disambiguation was guarding against — and it is what turned Type Check · workspace red (run 33932953271, job 101215162163, head 99999540a): TS2322: Type 'number' is not assignable to type 'undefined' at :119, TS18048 at :121. Both are the retiredKey tombstone type doing precisely what it is for. Fixed by moving the literal to timeoutMs at the same magnitude — milliseconds in and out, no value conversion.

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.json carries no Tenant* row at all (the only tenant rows are cloud/ProvisionTenantRequest:*), so no RETIRED_KEYS_BY_MAJOR entry 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 as minor during the launch window; the BREAKING banner and the ADR-0087 disposition are the carriers) + pr-automation.yml "WHICH LEVEL" + precedent in packages/spec/CHANGELOG.md (**BREAKING** … shipped as minor):

  • @objectstack/spec minor, BREAKING banner, adr-0087: registered naming the five ids;
  • @objectstack/metadata minor, BREAKING (DatabaseLoaderOptions.cache.ttl → ttlMs is an exported interface member), adr-0087: registered metadata-manager-config-cache-ttl-unit-in-key (the gate refused already-registered for an id this diff adds — measured, corrected);
  • @objectstack/objectql, @objectstack/service-job, @objectstack/runtime patch — 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 package skills/objectstack-data/**/*.md: 3736 → 3736. All skills/**/SKILL.md: 6835 → 6835. Net zero; no re-wrap, no content bought.

Out-of-scope finding, filed (not ridden along)

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.
  • vitest, spec: 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).
  • vitest, readers: metadata database-loader.test.ts 86 passed; objectql hook-binder + hook-metrics 32 passed; service-job db-job-adapter.timeout + cron-job-adapter + interval-job-adapter 40 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 (needs client-react built first — measured: a stale-dist refusal until then).
  • Gate families derived with 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-keys 1 (the 65 remaining offenders, by design); check:dual-build-cjs-loads 3 PREREQUISITE NOT MET (needs a full pnpm build; NOT MEASURED); check-engine-split-ratio --days 90 2 (shallow clone refusal; NOT MEASURED); check-required-contexts --verify-required-set 2 in the farm (HTTP 401 without the proxy) and 0 re-run as NODE_OPTIONS=--use-env-proxy; check:pm-dispatch-gates run detached per its own header → ✓ dispatch-gates self-test: 1445 cases pass. (exit code not captured — detached).
  • check:docs-audit-scope red 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-examples leaves packages/spec/.examples-build/ behind, and check:docs-audit-scope then fails its own self-test in the same working tree #15446 records exactly this sequence — check:skill-examples leaves packages/spec/.examples-build/ behind and the docs-audit self-test then admits it — and the farm had run check:skill-examples immediately before. Duplicate-searched before concluding (control query answered spec: duration-shaped number keys carry their unit in describe prose only — two ttl keys with different units in one block, bare timeout keys, 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) plus git diff HEAD empty:

  • A — inject one offender (src/zz-ablation-offender.zod.ts, cooldown "in seconds"): 65 → 66 offenders, the injected site named; file removed → 65.
  • B — --root on scratch trees: a tree of three compliant keys (ttlMs, retentionDays, a { value, unit } pair) → exit 0, "zero offenders"; the same tree plus one ttl "in seconds" → exit 1, 1 offender.
  • C — mutate the detector (prose regex made unmatchable, grep -c 1 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, as check-self-test-wired requires.
  • ADR-0087 refusals: each old spelling is pinned to fail with the rename prescription (see the five test files), and the D2 fixtures replay in 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, timeout to timeoutMs. Scope was the type check and nothing else — the gate's 65 remaining offenders are untouched and Lint & Repo Gates stays red by design.

  • Swept by TYPE, not by name. Every file naming DriverOptions outside node_modules (73 of them) was read for the retired spelling, and separately every bare timeout: / ttl: key literal and every .timeout property read under packages/**, apps/** and examples/**. 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-form z.record), LRUCache.ttl, lifecycle.ttl, the SMTP transport timeout and HealthCheckConfig.timeout are all other schemas, and the surviving ttl readers already spell ttlMs / ttlSeconds.
  • Reproduced before fixing. pnpm --filter @objectstack/driver-turso typecheck on 99999540a reproduced the two CI errors verbatim — VERDICT command-exit 2.
  • Green after. The same command plus pnpm --filter @objectstack/driver-turso test — VERDICT command-exit 0, Test Files 44 passed (44), Tests 1159 passed (1159).
  • The CI job's own command, re-run on e68ae2b5: pnpm exec turbo run typecheck --concurrency=2 --filter='./packages/*' --filter='./packages/*/*' --filter='./apps/*' gives Tasks: 135 successful, 135 total — 70 of them typecheck tasks, all five driver packages among them, zero error 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 on 99999540a, and here every one of them ran.
  • No changeset owed — derived, not guessed. The one changed file is a *.test.ts. @objectstack/driver-turso publishes files: ["dist","README.md","CHANGELOG.md"] built from a src/index.ts entry, and check:published-files re-confirms every publishable package "admits no test": the edit releases nothing, which is case 2 of pr-automation.yml "WHICH LEVEL". No public surface of that package moves — the type that moved is DriverOptions, and @objectstack/spec already carries the BREAKING changeset for it. The skip-changeset label is not the remedy either: that is for a PR releasing nothing at all, and this one releases plenty.
  • Gates re-run on e68ae2b5, each exit code captured by redirecting to a file before any pipe: check:nul-bytes 0, check:cross-package-test-inputs 0, check:test-source-alias 0, check:type-source-resolution 0, check:published-files 0. check:duration-unit-keys 1, 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.
  • Filed, not ridden along: finding: check:duration-unit-keys scans packages/spec/src/** only — the same offender shape exists in workspace packages the gate never reads #15642 — the gate's declared population is packages/spec/src/** only, and the same offender shape exists outside it (measured: --root ../drivers/driver-turso/src reports 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

@github-actions

github-actions Bot commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 11 package(s): @objectstack/cli, @objectstack/core, @objectstack/driver-memory, @objectstack/driver-turso, @objectstack/metadata, @objectstack/objectql, @objectstack/platform-objects, @objectstack/runtime, @objectstack/service-datasource, @objectstack/service-job, @objectstack/spec, touching 190 documentable anchor(s). ⚠️ 83 changed file(s) yielded no anchor (packages/metadata/README.md, packages/objectql/src/hook-binder.ts, packages/runtime/src/endpoint-executor.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

42 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json a4816a79d0396d0fd10696cdf95d66e55aef92d3.

⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 83 changed file(s) yielded no anchor (packages/metadata/README.md, packages/objectql/src/hook-binder.ts, packages/runtime/src/endpoint-executor.ts, …) — pages documenting those are invisible to this run
  • 20 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 — 147 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 a4816a79d0396d0fd10696cdf95d66e55aef92d3 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 37d908e5307a87afe119f3963ec8093f7e66617a — the merge of head 03335ab6d632829b7fc16f924fe9c49e45050779 into base a4816a79d0396d0fd10696cdf95d66e55aef92d3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 37d908e5307a87afe119f3963ec8093f7e66617a && git checkout 37d908e5307a87afe119f3963ec8093f7e66617a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a4816a79d0396d0fd10696cdf95d66e55aef92d3 03335ab6d632829b7fc16f924fe9c49e45050779 && git checkout -B drift-repro a4816a79d0396d0fd10696cdf95d66e55aef92d3 && git merge --no-ff 03335ab6d632829b7fc16f924fe9c49e45050779

node scripts/docs-audit/affected-docs.mjs --json a4816a79d0396d0fd10696cdf95d66e55aef92d3

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

`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
Three text conflicts, all co-insertions — both intents stack, nothing dropped:

  packages/spec/src/migrations/registry.ts
    step 18's hand-written `rationale` (both sides appended a sentence: main's
    keeps its adjacency to the families it names, this branch's "Finally, …"
    stays final), plus one add/add in the generated `semantic:18` region
    (`epoch-instant-keys-renamed` beside `esignature-config-deadline-keys-retired`)
    and one in `retired-key:18` (`data/FilePersistenceConfig:autoSaveInterval`
    beside `data/ESignatureConfig:reminderDays`). The two generated regions are
    re-derived from `src/migrations/entries/**` by the regeneration commit that
    follows; the entry files themselves merged as pure adds.

  packages/spec/src/type-alias-convention.pin.test.ts
    a machine-checked count stated in three places. 825 (base) - 13 (main's
    whole-family retirement) + 1 (this branch's `EpochMs`) = 813, and 813 is
    what the file's own regex counts in the merged tree — not arithmetic.

  packages/spec/llms.txt
    the schema inventory total. 207 - 3 + 1 = 205, matching the per-domain
    table (which merged cleanly) and the merged `*.zod.ts` census.

The 16 `merge=os-regen` paths both sides moved take origin/main's side here,
per scripts/pm/os-regen-merge.sh step 2; the regeneration commit that follows
re-derives them on the merged tree. No `content/docs/releases/` edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…4478)

Discharges the os-regen deferral the merge commit recorded. Regenerated in the
order the generators require — `gen:migration-registry` writes a source file, so
the spec is rebuilt before `gen:api-surface` reads `dist/*.d.ts`:

  gen:migration-registry, build, gen:api-surface, gen:export-origins,
  gen:declaration-map, gen:docs

`check:generated` then reports all 15 generated artifacts up to date.

The ledger keeps every row from both sides and invents none: 356 entry files at
the merge base, +27 from main, +80 from this branch, 463 in the result — set
equality, not just a matching total. `check:migration-registry` reads the same
population back as 186 semantic, 161 retired-key, 116 retired-def.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4

os-sales commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Ready for the human merge — and first, a correction to something I said twice on this PR

os-sales / session_01G4138K1EG7kQ81FNba5Kp4, 2026-09-06T02:20Z, on head 03335ab6d.

⛔ The correction: six required contexts, not seven

At 5550141522 and again at 5550293494 I wrote that Lint & Repo Gates is "one of 7 required contexts" and listed Governed Surface Queue Guard among them, presenting it as read off scripts/check-required-contexts.mjs.

That was wrong, and not because the file changed under me — it never said that:

grep -c "Governed Surface Queue Guard" scripts/check-required-contexts.mjs
  b25a5fc32 (this PR's original base) → 0
  c2a336ca2 (the main I measured against that morning) → 0
  1f2a02ba0 (main today) → 0

The registry's own history says so out loud: commit d57333b81, "scan the PM readings ledger for the six required names (#15727)". The pinned six are Lint & Repo Gates · TypeScript Type Check · Test Core · Build Core · Dogfood Regression Gate · Temporal Conformance (live PG + MySQL). Governed Surface Queue Guard is a real workflow (.github/workflows/governed-surface-guard.yml) — it is simply not one of the pinned contexts, and I folded it in without checking.

⇒ The argument those two comments made is unaffected: Lint & Repo Gates is required either way, so a red gate would still have blocked the repository. Only the count was wrong, and it was stated as a measurement.

The landing blocker filed at 5554574094 is closed

The scheduled one-line edit is applied and verified on this head, by me, not relayed:

node scripts/check-adr-0087-registration.mjs --base origin/main   → EXIT 0
✓ check-adr-0087-registration: 9 declared-breaking changeset(s), each carrying an ADR-0087 disposition.
  .changeset/driver-turso-config-timeout-ms.md  [BREAKING+bang]  registered turso-config-timeout-to-timeout-ms (new here: turso-config-timeout-to-timeout-ms)

The marker reads the bare pre-verified form, with the per-card explanation kept as a second comment on its own line — the spelling that survives the parser, since trailing prose on the marker line is read as further ids.

State

All six cards are in (#15814, #15837, #15906, #15938, #15988, #16022), each landed as a squash because the API refuses a merge commit here, and each descendant paid a repair lap for it.

main has moved twice since the lap and this branch still merges clean against today's tip 1f2a02ba0 — measured both ways, and the readings agree byte for byte:

git merge-tree --write-tree origin/main 03335ab6d                              → exit 0, tree 5d2af999f
git -c merge.os-regen.driver=false merge-tree --write-tree origin/main 03335ab6d → exit 0, tree 5d2af999f

The gate this campaign exists for, on the merged tree, exit 0: "215 duration-shaped numeric key(s) across 2320 source file(s) all carry their unit in the key name … zero offenders, no baseline." Both numbers moved from the pre-merge 217 / 2291 and both moves are main's, censused rather than reasoned: two document.zod.ts keys left with main's ADR-0049 e-signature retirement, and the file count tracks 33 added minus 4 deleted source files inside the walked roots.

content/docs/releases/ — 0 files changed by this branch.

What is left

⛔ A human merge. This PR touches skills/** in four files, so no agent seat may merge it. And this is the first time the stack gets the six required contexts at all — every card in it was based on a feature branch, and lint.yml / ci.yml both declare pull_request: branches: [main], so none of them triggered. Whatever CI says here is the first real reading; the local runs above stand in only for parts of TypeScript Type Check, Test Core, Build Core and a slice of Lint & Repo Gates, and not at all for Dogfood Regression Gate or Temporal Conformance.


Generated by Claude Code

@os-zhuang
os-zhuang added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit e9fcd6b Sep 6, 2026
37 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-14478-duration-unit-in-key-name branch September 6, 2026 03:17
os-bill pushed a commit that referenced this pull request Sep 17, 2026
The `dimensionless` schema marker landed with its gate reader and no docs
half. Ruling B on #14478 put both halves in the mechanism: a marker
"declared ON THE SCHEMA, never in a gate ledger … the gate honours and the
docs generator publishes". Its sibling `externalVocabulary` has had its
renderer since #15626; this one had none, so a key marked `dimensionless`
would carry an in-schema declaration the published reference page says
nothing about.

Adds `dimensionlessNote()` beside `externalVocabularyNote()` in
`scripts/lib/schema-section.ts`, appended to the same description cell, and
reading the same declaration the gate reads: a non-empty string literal, so
an empty or non-string marker publishes nothing.

No key is marked by this change — the live population stays 0 — so no
reference page moves and no published payload changes.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…bjectstack-ai#18684)

Fixes objectstack-ai#18500
Clause-②: no

PR objectstack-ai#18486 landed the `dimensionless` schema marker with its **gate
reader** and no **docs half**. Ruling B on objectstack-ai#14478 put both halves in the
mechanism, verbatim:

> declared ON THE SCHEMA, never in a gate ledger … marker the gate
honours **and the docs generator publishes**

The sibling marker `externalVocabulary` has had its renderer in
`packages/spec/scripts/lib/schema-section.ts` since objectstack-ai#15626.
`dimensionless` had none, so a key marked `dimensionless` would carry an
in-schema declaration that the published reference page says nothing
about.

The gate's own header already asserts the half that did not exist.
`check-duration-unit-keys.ts`, exemption class 4: *"Same mechanism as
class 2, same literal-only validation, **same visibility**."* Class 2 is
`externalVocabulary`, and its visibility **is** that renderer. This PR
is what makes that sentence true.

## What landed

- `dimensionlessNote(prop)` in
`packages/spec/scripts/lib/schema-section.ts`, beside
`externalVocabularyNote(prop)`, appended to the same description cell
rather than given a column.
- Six pins in `packages/spec/scripts/schema-section.test.ts`, mirroring
the sibling's block one-for-one.

A key described `Failures seen in the last 5 minutes` and carrying
`.meta({ dimensionless: 'failed attempts' })` now publishes:

```
Failures seen in the last 5 minutes (dimensionless — counts failed attempts)
```

### Why that wording

The card suggested "counts `what`" and wrote "⛔ no ruling implied", so
the shape was read off the declaration instead of taken from the
suggestion. The marker's declared value is a **non-empty string literal
naming what the number counts** — `check-duration-unit-keys.ts` refuses
an empty string and a computed value, because an unverifiable claim
exempts nothing — so the declared value is printed verbatim, and the
same non-empty-string-literal test decides whether anything is printed
at all. The word `dimensionless` is kept in front of it for the one
thing the value alone does not state: the exemption's claim is that the
number has **no unit**, which is exactly the ambiguity a reader has when
the prose beside a naked number names a time unit. It also keeps one
vocabulary across the marker name, the census line the gate prints, and
the ruling.

## The three questions the dispatch asked

**1. Which gate can actually go red on this change?**

`packages/spec/scripts/schema-section.test.ts`, run by `pnpm --filter
@objectstack/spec test` (vitest project `local`; the file is not listed
in `vitest.repo-tests.json`) — in CI, `Test Core`. `check:docs` and
`check:generated` structurally cannot move: the live population is **0**
keys marked, so no reference page changes and `check:docs` is green both
before and after. Established by ablation, not assumed — below.

**2. Does the marker ride into the published JSON Schema via
`z.toJSONSchema`?** — **Yes.**

- Direct, on the zod this workspace installs (4.4.3): `z.number().meta({
dimensionless: 'failed attempts' })` emits `"dimensionless": "failed
attempts"` on the node, unchanged. A fabricated marker name rides
identically, so the channel is generic rather than per-key.
- On the real generated tree, after `pnpm --filter @objectstack/spec
gen:schema`: 18 files under `packages/spec/json-schema/` carry
`"externalVocabulary"` (LIT control), **0** carry `"dimensionless"`,
**0** carry the fabricated name (DARK controls).
`packages/spec/json-schema/` has 0 tracked files, so this cannot be read
from git — it was built and read.

⇒ **the docs generator is not the only publisher.** `json-schema` is in
`packages/spec/package.json` `files[]`, so a marked key would also ship
inside the npm tarball. Nothing is marked by this PR, so nothing moves
today — but the *second* publisher is a fact the first card to mark a
key inherits.

**3. What should it print?** — answered from the declared value shape,
above.

## Clause-② and the changeset

Declared `no`, unchanged — the dispatch reserved that line, and question
2's answer does not move it for **this** diff: no key is marked, so no
published byte changes.

`skip-changeset`, measured rather than assumed:

- `packages/spec/package.json` `files[]` = `dist`, `json-schema`,
`liveness`, `prompts`, `llms.txt`, `README.md`, `src/**/*.zod.ts`,
`CHANGELOG.md`, `api-surface`, `spec-changes.json`. `scripts/**` is not
among them.
- `dimensionlessNote` after the build: **0** hits in every one of those
paths. Positive control on the same greps, `ObjectSchema`: `api-surface`
2, `llms.txt` 1, `README.md` 1, `src/**/*.zod.ts` 14.
- `dist` cannot carry it either way: every `tsup` entry in
`packages/spec/tsup.config.ts` is under `src/`, so `scripts/**` is not
an entry.
- `check:docs` green ⇒ **0** reference pages move.

⚠️ For the seat that marks the first key: at that moment the marker
reaches **both** publishers, and `json-schema/` is published — so that
card's declaration and changeset are a different question from this
one's.

## Testing

Union run at `d6f2148892` (the final commit; gate logs carry no sha, so
this is the tree every number below was taken on).

| run | result |
|---|---|
| `pnpm --filter @objectstack/spec test` (project `local`) | 484 files
passed / 1 skipped, **13872 passed** / 1 skipped |
| `pnpm --filter @objectstack/spec typecheck` (incl.
`check:scripts-typecheck`, `check:test-typecheck`) | exit 0 |
| `pnpm --filter @objectstack/spec check:docs` · `check:variant-docs` ·
`check:liveness` · `check:empty-state` · `check:strictness-ledger` |
exit 0 each |
| `pnpm lint` (`eslint . --no-inline-config`, whole repo — not narrowed)
| exit 0 |
| `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--ran ...` | **57 derived, 52 run, 5 NOT MEASURED, 0 UNRUN** |

The 5 NOT MEASURED are `check:dts-closure`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:sourcemap-no-sources-content`, `check:type-check-debt` — each
exited **3**, `PREREQUISITE NOT MET`, which those gates spell out as
"not a pass and not a finding". All five read built output of the whole
monorepo, which this worktree does not have; CI's `Build Core` is where
they are measured. This diff adds no export, no entry point and no
emitted file, so it has no channel to reach any of them.

### Red before green — ablation with an on-disk proof and a
hash-verified restore

Run from the committed state, under a `trap ... EXIT INT TERM`, against
absolute paths:

1. `HEAD:packages/spec/scripts/lib/schema-section.ts` blob =
`a92ba895b1f51e8eaf8945e45f8bf8c12ea77e90`.
2. Anchor count before mutation: **1**. Mutation deletes the
`dimensionlessNote(prop)` term from the description cell.
3. **Landed on disk**, both directions: deleted text **0** occurrences
after, injected marker **1** occurrence after; mutated blob
`4f00017eab...` differs from the HEAD blob.
4. Red leg: `Tests 4 failed | 39 passed (43)` — the four cases that
assert the note is printed. The two that assert its **absence** stayed
green, which is the predicted direction and what keeps the note from
decorating every row in the reference.
5. Restore leg: `git checkout HEAD -- path` (never a bare `git checkout
--`), then hash re-read = `a92ba895b1...`, matching, **and** `git diff
HEAD` empty. Green re-run at the final head: 43 passed.

Predicted direction was stated before the run and matched: turn red, not
"more diagnostics" and not "inverted".

## `skills/**` derivative

**None.** Reading, not expectation: the diff is exactly 2 files (`git
diff --name-only` against the merge base), **0** of them under
`skills/`. `packages/spec/scripts/build-skill-docs.ts` does not import
`schema-section`, and `check:skill-docs` / `check:skill-refs` were not
derived for these paths. So no net-line-count reading is owed.

## Acceptance notes

Out of scope, noted and deliberately not filed:

- **The `.meta()` channel is generic, not a marker allowlist.** A
fabricated key name rides `z.toJSONSchema` into `json-schema/` exactly
as `externalVocabulary` and `dimensionless` do, and `json-schema/` is
published. This is the documented mechanism (`xRef` / `xExpression` /
`xEnumDeprecated` use the same channel by design), not a defect, and
none of the three filing classes fits: no repro, no contract violated,
and the hazard is metadata being *published* rather than silently
dropped or refused. Who will meet this file next: objectstack-ai#18124 step 4, the
card that will first mark keys.
- **Nothing mechanically ties "a marker the gate reads" to "a renderer
in `schema-section.ts`".** The next marker added to
`check-duration-unit-keys.ts` can repeat exactly this half-build. Not
filed: it is undemonstrated drift today, ruling B is honoured on the
tree as of this PR, and both markers that exist now carry both halves.

## Dispatch fences observed

`packages/spec/src/migrations/**` and
`scripts/check-cross-package-test-inputs.mjs` untouched (the latter
moved on `main` under objectstack-ai#18667 and arrived through a plain merge of
`origin/main`, with no conflict and no edit by this branch).
`packages/spec/scripts/check-duration-unit-keys.ts` read only. No live
key was marked `dimensionless` — population stays 0, by measurement
above.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…stem-* migration entries states each lesson in words, not tracker numbers (stage 3) (objectstack-ai#20384)

Part of objectstack-ai#20233

Clause-②: no

**Stage 3 of a staged card.** The card stays open for later stages; this
PR carries no closing keyword. Text only: no entry id, `surface`, `from`
/ `to`, conversion or matching logic moves, and the chain rewrites
exactly what it rewrote before.

## What this does

`os migrate meta` prints every ADR-0087 semantic entry it crosses as one
block: `⚠ [protocol N] SURFACE → REPLACEMENT`, then `why:` (the entry's
`reason`) and `verify:` (its `acceptanceCriteria`). AGENTS.md's
runtime-string rule applies to all of it: 「Runtime strings — refusal
prose, prescriptions, anything an author is shown — carry no tracker
number (`pnpm check:doc-authoring`): the lesson goes into the text.」
Form **D** of ruling C+D on the parent card sets the shape: the lesson
in words, and no number, dead or alive.

This stage covers the next three families by site count, `driver-`,
`kernel-` and `system-`: **132 sites → 0** in the three prose fields.
None of the 26 entries carries a tracker id in `surface` (ruling A of
the stage-1 ACCEPT, `5858839916`, is checked and has nothing to do
here). Each site now says what the cited ruling, measurement or fix
decided. ADR ids stay. `registry.ts`, `spec-changes.json` and
`docs/protocol-upgrade-guide.md` are regenerated from the entries
(`gen:migration-registry`, `gen:spec-changes`, `gen:upgrade-guide`),
never hand-edited. The stage-1 pin now holds `engine-`, `ui-`,
`plugin-`, `driver-`, `kernel-` and `system-`.

## Census — tracker ids in the author-shown fields

**Instrument.** The stage-2 AST instrument, unchanged: a TypeScript-AST
walk over every `packages/spec/src/migrations/entries/**/*.ts`. For each
`entry` object literal it evaluates the string value of `replacement`,
`reason`, `acceptanceCriteria` and (counted separately) `surface`,
joining string literals with `+`, then counts `#` followed by 4 or 5
digits at a word boundary. **Validated first** by reproducing the
stage-1 readings on the stage-1 tree (`443b2f4fdc`, extracted with `git
archive`): `driver-` 7 entries / 44 sites (0 / 44 / 0, 25 distinct),
`kernel-` 9 / 44 (1 / 41 / 2, 11 distinct), `system-` 10 / 44 (0 / 42 /
2, 6 distinct), `engine-` 5 / 67, whole tree 266 entries / 1,016 sites /
9 `surface` sites — every figure equal to the stage-1 census. **Tree
measured:** `objectstack-ai/objectstack` at `569d4d2dbf` (this branch's
base). Unevaluable fields: 0.

**Controls, same run.**
- **Lit:** `17.aggregation-node-distinct-retired.ts` reads 7 sites
(replacement 1, reason 6), the reading stages 1 and 2 took.
- **Dark (comment lines):** 794 `//` lines in entry files carry a
tracker id, and none is counted. Comment lines belong to the sibling
card, and ⛔ this PR touches none (794 before and after).
- **Dark (field boundary):** the 7 `surface` sites left in the tree
(other families) count 0 in the three-field total and 7 in the `surface`
column.

**Re-measured on the base, matching the stage-1 census:** `driver-` 7
entries, **44** sites (replacement 0 / reason 44 / acceptanceCriteria
0), 25 distinct ids; `kernel-` 9 entries, **44** (1 / 41 / 2), 11
distinct; `system-` 10 entries, **44** (0 / 42 / 2), 6 distinct. 37
distinct ids across the three (the families share `objectstack-ai#14478`, `objectstack-ai#15939`,
`objectstack-ai#17635` and `objectstack-ai#3733`). `surface`: 0 in all three. Whole tree: 300
entries, **843** sites, 7 `surface` sites.

**After this PR:** `driver-` 0, `kernel-` 0, `system-` 0; `engine-`,
`ui-`, `plugin-` still 0; whole tree **843 → 711** sites; `surface` 7
(unchanged, other families).

| entry | sites (replacement / reason / acceptanceCriteria) |
|---|---|
| `17.driver-aggregate-undeclared-key-aliases-removed` | 6 (0 / 6 / 0) |
| `17.driver-capabilities-inert-bits-removed` | 4 (0 / 4 / 0) |
| `18.driver-options-timeout-to-timeout-ms` | 1 (0 / 1 / 0) |
| `17.driver-sql-distinct-bare-filter-typed` | 9 (0 / 9 / 0) |
| `18.driver-sql-unresolvable-where-column-refused` | 14 (0 / 14 / 0) |
| `18.driver-sql-upsert-cross-row-identity-merge-refused` | 9 (0 / 9 /
0) |
| `18.driver-turso-config-local-path-wasm-retired` | 1 (0 / 1 / 0) |
| `18.kernel-compatibility-matrix-estimated-migration-time-unit-in-key`
| 5 (0 / 5 / 0) |
| `18.kernel-context-preview-mode-retired` | 5 (1 / 4 / 0) |
| `18.kernel-event-bus-retention-unit-in-key` | 3 (0 / 3 / 0) |
| `18.kernel-health-check-and-hot-reload-durations-unit-in-key` | 7 (0 /
5 / 2) |
| `18.kernel-package-lifecycle-durations-unit-in-key` | 3 (0 / 3 / 0) |
| `18.kernel-plugin-health-report-durations-unit-in-key` | 3 (0 / 3 / 0)
|
| `18.kernel-plugin-security-durations-unit-in-key` | 4 (0 / 4 / 0) |
| `18.kernel-runtime-config-timeout-unit-in-key` | 11 (0 / 11 / 0) |
| `18.kernel-startup-orchestrator-durations-unit-in-key` | 3 (0 / 3 / 0)
|
| `18.system-cache-durations-unit-in-key` | 3 (0 / 3 / 0) |
| `18.system-collaboration-durations-unit-in-key` | 4 (0 / 4 / 0) |
| `18.system-failover-health-check-interval-unit-in-key` | 3 (0 / 3 / 0)
|
| `18.system-metrics-jsdoc-durations-unit-in-key` | 12 (0 / 12 / 0) |
| `18.system-metrics-window-durations-unit-in-key` | 5 (0 / 3 / 2) |
| `18.system-object-storage-durations-unit-in-key` | 3 (0 / 3 / 0) |
| `18.system-registry-config-durations-unit-in-key` | 3 (0 / 3 / 0) |
| `18.system-tracing-otel-exporter-durations-unit-in-key` | 5 (0 / 5 /
0) |
| `18.system-tracing-span-duration-unit-in-key` | 3 (0 / 3 / 0) |
| `18.system-worker-queue-rate-limit-duration-unit-in-key` | 3 (0 / 3 /
0) |
| **total, 26 entries** | **132 (1 / 127 / 4)** |

## Every citation read, and what the text now says

I read each cited issue or PR myself with single-card REST reads: the
body, and the comments where a ruling or a measurement lives. Ids are in
code spans so this body posts no cross-references. All 36 bare ids were
resolved against this repository, because every sentence that cites one
is about this repository's code; the one cross-repo id is `cloud#1651`.

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `objectstack-ai#3733` | The pruned `cached` field key: measured, the parse succeeded
and the removed key was dropped without a word; the orphan schema was
deleted. | "an earlier field-key prune measured exactly that — the parse
succeeded and the removed key was dropped without a word" (health-check,
OTel exporter) |
| `objectstack-ai#3821` | The sharing-rule page: an unsortable query fell through to
an empty page, and the driver fix made an unsortable query lose its
ORDER BY, not its rows. | "the unknown-column recovery ladder (an
unsortable query loses its ORDER BY, not its rows)"; "the ladder's own
premise — rows matter more than their order"; "the ladder's recoveries"
|
| `objectstack-ai#4484` | `IDataDriver.findStream` removed: no production caller, two
of three implementations buffered the whole set, and no tombstone
because nothing parses a driver object. | "Retiring
`IDataDriver.findStream` (it had no production caller, and two of its
three implementations read the whole result set into memory …)";
"(`IDataDriver.findStream`, removed with no tombstone because nothing
parses a driver object)"; by entry id in the `distinct` entry |
| `objectstack-ai#4583` | The datasource ledger's dead keys removed; `capabilities.*`
went as a whole block (11 of 11 unread). | "was retired separately, as a
whole block nothing read" |
| `objectstack-ai#4634` | Audit of all 34 `DriverCapabilities` bits: 3 live, 31 dead
and tombstoned. | the entry already states the audit ("the follow-up
audit checked every bit"); the trailing id is dropped |
| `objectstack-ai#4914` | Maintainer, 2026-08-04: remove `manifest.loading` and
`PluginHotReloadSchema`; keep `HotReloadConfigSchema`, the side with an
implementation (`HotReloadManager`), as the start point. | "kept twice:
as the hot-reload vocabulary that had an implementation when the
manifest-side copy was removed, …" |
| `objectstack-ai#4984` | An org-axis red-line gate read only aliases the schema
rejects while its own fixtures spelt them: tests green, rule dead. |
"the family of the org-axis red-line gate that read only rejected
aliases while its own fixtures spelt them, so its tests stayed green and
the rule stayed dead" |
| `objectstack-ai#5181` | Narrow the query parameter of `IDataDriver`'s methods
(`DriverQuery`, no redundant `object`). | "neither the narrowing of
`IDataDriver`'s query parameters to `DriverQuery` nor the follow-through
…" |
| `objectstack-ai#5499` | Maintainer, 2026-08-05: freeze investment in `driver-memory`
/ `driver-mongodb`; fully lifted 2026-08-11 (comments `5249019855`,
`5252526378`). | "the maintainer's 2026-08-05 investment freeze on
driver-memory, which was lifted on 2026-08-11" |
| `objectstack-ai#5540` | Remove `IStorageService.list(prefix)`: zero consumers, and
the two adapters answered differently and both incompletely. | "(the
zero-consumer `IStorageService.list`, whose two adapters answered
differently and both incompletely)" |
| `objectstack-ai#6011` | Maintainer: close the `ctx.user` `roles` alias now. | "(the
`ctx.user` `roles` alias, closed at once on the maintainer's word rather
than given a window)" |
| `objectstack-ai#6075` | **404** — see Acceptance notes. | "the follow-through that
brought five drivers' implementations in line" |
| `objectstack-ai#6320` | `distinct`'s third argument meant different things on memory
and sql; the sql half was dispatched, the memory half held under the
freeze. | "(the measurement that found the two drivers reading this
argument differently split the fix: the sql half is this entry, and the
memory half was held back by that freeze)" |
| `objectstack-ai#6321` | `query.aggregate` / `agg.func` are undeclared aliases whose
only writers are driver fixtures; order: re-spell the fixtures, delete
the aliases, then narrow the signature. | "The removal ran in a fixed
order — the fixtures re-spelt first, the two alias branches deleted
second, the parameter narrowed to `DriverQuery` last — because the
reverse order yields red nobody can explain." |
| `objectstack-ai#6404` (PR) | Executed that order and narrowed `aggregate`'s query
parameter to `DriverQuery`. | the same sentence |
| `objectstack-ai#7929` | Maintainer, 2026-08-12, ruling B: `driver-sql`'s filter
refusal stops echoing `$field` operands, for every caller; the full
diagnostic goes to the server log. | "the same predicate-text disclosure
shape the driver's field-reference filter refusals had already been made
to stop echoing (the full diagnostic goes to the server log, never the
response)"; "that disclosure shape closed on the last dialect" |
| `objectstack-ai#8371` | Ruled option 2: a dotted filter key whose head is a
relation, a formula or a scalar is refused at both doors; a structured
head stays unjudged. | "the axis owned by the dotted-filter verdict,
which refuses a dotted key whose head is a relation, a formula or a
plain column at the protocol and engine doors" |
| `objectstack-ai#8592` | Measured on live MySQL: knex compiles the named conflict
target away. | stated by the entry ("knex drops the named keys before
the statement leaves the process"); the trailing id is dropped |
| `objectstack-ai#8621` | Option A: a pre-flight refusal when no unique index backs
the caller-named conflict target. | "Two earlier pre-flight refusals
closed the half where no unique index backed a caller-named target …" |
| `objectstack-ai#8622` | `id` becomes insert-only on the merge path: a merge on a
non-primary conflict key was measured rewriting the existing row's
primary key. | "`id` is insert-only on the merge path (made so once a
merge on a non-primary conflict key was measured rewriting the existing
row's primary key)" |
| `objectstack-ai#8755` | Ruling option A: a pre-flight refusal when a second unique
key could absorb a backed, caller-named target. | "… and the half where
a rival unique key could absorb a caller-named one" |
| `objectstack-ai#8790` | Maintainer, 2026-08-15: refuse both halves with
`INVALID_FILTER` / 400, naming the column. | "Ruled by the maintainer on
2026-08-15: refuse BOTH halves …"; "Recover-both was excluded by the
ruling's own argument" |
| `objectstack-ai#8807` | Maintainer, 2026-08-15: an upsert must never modify a row
whose identity the caller did not supply and whose conflict key it did
not name; enforcement delegated, blanket refusal excluded. | "Ruled by
the maintainer on 2026-08-15, as a contract principle …" (the principle
itself was already quoted verbatim) |
| `objectstack-ai#8926` | Maintainer, 2026-08-16, option A: MySQL's spelling joins the
one shared predicate (envelope and recoveries together). | "Addendum
2026-08-16." — the paragraph already states option A |
| `objectstack-ai#9061` (PR) | Implemented that option A. | the same |
| `objectstack-ai#11825` | Maintainer, 2026-08-25: retire the declarative
`AdvancedPluginLifecycleConfig` container; the classes stay a
host-driven library. | "… and as a host-driven library when the
declarative lifecycle config container was retired" |
| `objectstack-ai#11846` | **404** — see Acceptance notes. | "maintainer ruling
2026-08-27 (Option A: remove)"; "(as the removal ruling recorded)" |
| `objectstack-ai#14478` | Ruling B, 2026-09-02: a no-baseline gate plus an ADR-0087
rename of every offender (`DriverOptions.timeout` among the seven
named); ruling B again, 2026-09-05: the population is every authored and
every runtime-emitted duration, minus exemptions declared on the schema.
| "Maintainer ruling B on duration units (2026-09-02, its population
widened on 2026-09-05 to every authored and every runtime-emitted
duration, bar the exemptions a schema declares on the key itself)"; "the
duration-unit rule (…)" |
| `objectstack-ai#14519` | The two tenant timeouts published a describe naming no unit
(the unit sat in the JSDoc only); folded into the rename. | "the
unit-nowhere shape (no unit in the name or in the published describe,
first measured on two tenant timeouts)" |
| `objectstack-ai#15626` (PR) | Landed the gate and the seven founding renames, the
tenant `idleTimeout` → `idleTimeoutSeconds` among them. | "The tenant
half was already renamed, in the same change that landed the duration
gate itself" |
| `objectstack-ai#15678` | `kernel/`: the 14 remaining duration keys carry their unit
in the key name. | "the kernel-directory duration renames" / "the
kernel-directory round"; trailing ids dropped |
| `objectstack-ai#15679` | `system/`: the 15 remaining duration keys carry their unit
in the key name; `size` got an honest name. | "the system-directory
duration round"; trailing ids dropped |
| `objectstack-ai#15939` | The gate did not read JSDoc. Ruled 2026-09-07: refuse the
JSDoc / describe divergence. Ruled A 2026-09-11: remediate the
population per file first, land the widened gate last. | "Director-seat
ruling A of 2026-09-11 on the JSDoc-channel finding … a duration key
whose JSDoc names a unit its describe does not is refused, and the keys
in that shape are remediated per file before that refusal lands" |
| `objectstack-ai#16024` | Maintainer, 2026-09-06, per key: forward `timeout`, remove
`localPath` and `wasm`. | "ruled per key by the maintainer on
2026-09-06, once all three of this package's unread config keys had been
measured" |
| `objectstack-ai#17635` (PR) | The widened gate: refuse a duration key whose JSDoc
names a unit its describe does not; landed last. | "lands that widened
gate last, into a tree already clean" |
| `objectstack-ai#18669` | Ruling A, 2026-09-17: rename `FileValue.duration` and
`estimatedMigrationTime`, each with an ADR-0087 entry; no new closed
type, no narrowing of stored data. | "Maintainer ruling A of 2026-09-17
on the last two duration keys no closed duration type could express …" |
| `cloud#1651` | **Not readable from this session** — see Acceptance
notes. | "cloud — a census closed 2026-08-26: OS_PREVIEW_MODE there is a
routing-only switch …" |

No call-shaped token moves: a `name(` census over `registry.ts` is
identical before and after (297 distinct tokens), so textual
call-spelling ratchets read the same.

## Pin — `packages/cli/test/migrate-meta-engine-guidance.test.ts`,
widened

`COVERED_PREFIXES` is now `engine-`, `ui-`, `plugin-`, `driver-`,
`kernel-`, `system-`. The pin still spawns the real CLI (`os migrate
meta --from 16 --to 18`) once, locates each covered block **verbatim**
in stdout, and asserts the printed block — `surface` included — carries
no `#` plus 4 or 5 digits. Anti-vacuity:
- the `REWRITTEN` floor rises from 29 to **55** ids: the 26 entries of
this stage (7 `driver-`, 9 `kernel-`, 10 `system-`) are added, and every
covered prefix must still select at least one entry;
- presence in stdout is asserted before cleanliness (the
`driver-sql-unresolvable-where-column-refused` reason carries two
blank-line paragraph breaks, and its block is found verbatim);
- the detector is exercised on both sides first (lit on 4 and 5 digits,
dark on 3, 6 and `ADR-0112`).

The file keeps its stage-1 name; the header lists the six covered
families.

## Ablation — the widened pin can fail on a `driver-` block

From committed state, HEAD `1c0dc7ad54`, with
`scripts/ablation-replace.mjs` in wrap mode (it owns the restore trap)
and `scripts/ablation-dist-preflight.mjs` gating each leg. The bundle is
built from the generated `registry.ts`, so that is the file mutated.
- **Mutation.** In `registry.ts`, the reason of
`driver-sql-upsert-cross-row-identity-merge-refused`: anchor `pre-flight
refusals closed the half` → `pre-flight refusals (objectstack-ai#8621) closed the
half`. The tool read anchor 1 → 0 and replacement 0 → 1, blob `b41e1d44`
→ `8f8d226e`.
- **Mutate leg** (one lock turn: build, preflight, pin). Spec build exit
0. Preflight: marker present in 4 built files. Pin: **red**, `1 failed |
2 passed` — `driver-sql-upsert-cross-row-identity-merge-refused: the
printed guidance cites a tracker id: expected 'objectstack-ai#8621' to be undefined`.
- **Restore.** Tool-proven: blob `b41e1d44` == HEAD, `git diff HEAD`
empty.
- **Restore leg.** One lock turn, taken on the second try (the first
waited out its 540 s budget, exit 99, NOT MEASURED, the tree already
restored). Spec build exit 0. The `--absent` preflight found the marker
in none of 222 built files, with the working tree clean against HEAD.
Pin: **green**, `3 passed`.

## Verification

Final head **`1c0dc7ad54`** for every line below; each heavy run went
through `scripts/pm/os-verify-lock.sh` (one turn, `VERDICT command-exit
0`, per-step exits recorded separately).

- **Build:** `pnpm exec turbo run build --concurrency=2
--filter='@objectstack/cli^...'` gives `Tasks: 55 successful, 55 total`.
- **Pin and its neighbour:** `pnpm --filter @objectstack/cli exec vitest
run --project integration --maxWorkers=2
test/migrate-meta-engine-guidance.test.ts
test/migrate-meta-default-range.test.ts` gives `Test Files 2 passed`,
`Tests 10 passed | 1 skipped` (the skip is the default-range file's own
pre-existing `skipIf`).
- **Spec tests that read these entries or the registry:** `pnpm --filter
@objectstack/spec exec vitest run --maxWorkers=2 src/migrations
src/kernel/preview-mode-retirement.test.ts
scripts/build-schemas-check-mode.test.ts` plus the 19 other spec test
files that read `MIGRATIONS_BY_MAJOR`, the registry or an entry file:
`Test Files 24 passed`, `Tests 696 passed`.
- **CLI unit:** `test/vitest-tiers-partition.test.ts` and
`src/utils/spec-release-changes.test.ts`: `Test Files 2 passed`, `Tests
28 passed`.
- **The call-spelling census that reads `registry.ts`:** `pnpm --filter
@objectstack/driver-sql exec vitest run --maxWorkers=2
src/sql-driver-query-signature.test.ts` gives 15 passed.
- **Typecheck:** `pnpm --filter @objectstack/spec typecheck` exits 0
(test layer: 53 files / 255 errors held in its ledger); `pnpm --filter
@objectstack/cli typecheck` exits 0 (test layer: 3 files / 28 errors
held, unchanged).
- **Gate families:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derives **89** families at
`1c0dc7ad54` (after `git fetch origin main`). `--ran` over the recorded
exit codes reads **89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN**, all
exit 0. They include `check:doc-authoring` ("16466 customer-facing
string(s) across 1135 spec sources clean"), `check:issue-citations`,
`check:migration-registry` ("registry.ts is current (300 semantic, 219
retired-key, 199 retired-def)"), `check:spec-changes`,
`check:upgrade-guide`, `check:generated` ("All 15 generated artifacts
are up to date"), `check:duration-unit-keys`, `check:nul-bytes`,
`check:adr-0087-registration` and `check:changeset-no-major`.
- `check:dual-build-cjs-loads` refused first with `PREREQUISITE NOT MET`
(exit 3: twelve packages outside the CLI closure had no `dist/`). Those
`dist/` directories were written later in the same pass (04:48–04:49Z,
inside the `check:type-check-debt` run, whose re-measure builds them);
re-run at the same head it exits 0 (104 entries / 66 packages / 659 CJS
files). The reconciled list takes that latest run.
- **Lint (a proven narrowing, not the repo-wide run, which is CI's):**
`eslint --no-inline-config --format json` over the 28 changed `.ts`
files reports 28 files, 0 errors, 0 warnings.
- The population is read from `eslint.config.mjs`:
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`, and all 28
are in it (no file-ignored warning).
- Invariance: the config enables no type-aware linting (no
`parserOptions.project`, no typed rules), so a text edit cannot move the
verdict on a file it does not touch.
- **Mergeability:** see Acceptance notes (driver-free `merge-tree`
against `862b6ce869` exits 0).

## Acceptance notes

- **Two dead ids, rewritten from the code on `main`.** `objectstack-ai#6075` and
`objectstack-ai#11846` answer 404 on both the issues and the pulls endpoint, re-probed
with a 200 control (`objectstack-ai#14478`).
- `objectstack-ai#6075` (`distinct`'s "never reached it" sentence):
`packages/drivers/driver-sql/CHANGELOG.md` (commit `d367f03`) and
`sql-driver-query-signature.test.ts` record what it did — the five
drivers' implementations followed `IDataDriver`'s `DriverQuery`
narrowing. The sentence now says exactly that.
- `objectstack-ai#11846` (preview mode): `packages/spec/CHANGELOG.md` (commit
`0c2334f`), `packages/spec/src/kernel/context.zod.ts` and
`preview-mode-retirement.test.ts` record the 2026-08-27 ruling (Option
A: remove), the three-repo zero-consumer measurement and the
re-declare-fresh condition. Dropped because `main` does not state them:
"decision-inbox batch 2" and "all four decision facets pointed the same
way". "The objectstack-ai#11846 card records the measurement" (objectui leg) now reads
"zero consumers, measured when the removal was ruled", which is what the
changelog and the test header say.
- **One cross-repo id this session cannot read.** `cloud#1651` answers
403 here: `objectstack-ai/cloud` is not attached to this session
(`add_repo` refused: no access). It is neither confirmed nor refuted, so
its sentence was rewritten from what `main` records about it —
`packages/spec/CHANGELOG.md` (`0c2334f`: closed 2026-08-26 with positive
controls, `RuntimeMode` zero hits, `ArtifactKernelFactory` 20+ hits and
never touching `previewMode`) and `context.zod.ts` (`OS_PREVIEW_MODE`
there is routing-only). The cloud-side detail `main` does not state —
`previewMode` "only as a local variable" whose effect is adding
wildcards to "CSRF" trusted origins — is dropped; the parenthesis that
replaces it describes this repository's own `serve.ts` (the one reader
of `OS_PREVIEW_MODE` here only widens better-auth's trusted origins to
preview-domain wildcards), which is measured on `main`.
- **Decision-batch numbers went too (invisible to the regex).** Nineteen
sites cited a decision batch as `#` plus two or three digits (`objectstack-ai#43` ×13,
`objectstack-ai#115` ×4, `objectstack-ai#151` ×1, `objectstack-ai#158` ×1). They are numbers an author is shown
and cannot follow, so each is dropped. One consequence worth naming: 13
entries said `Maintainer ruling B on objectstack-ai#14478 (2026-09-02, decision batch
objectstack-ai#43)`, which fused two rulings on the same card — B of 2026-09-02 (the
gate and the no-baseline rename) and B of 2026-09-05, decided in that
batch (the population: every authored and every runtime-emitted
duration, minus schema-declared exemptions). The sentence now names both
dates. The `objectstack-ai#158` sentence (the agreement shape ruled an offence on
2026-09-18) is corroborated by
`.changeset/18075-agreement-shape-is-an-offence.md` on `main`.
- **"issue NNNN" / "PR NNNN" spellings, checked by hand.** No
bare-number spelling exists in these 26 entries' author-shown text; the
two `PR` citations (`PR objectstack-ai#6404`, `PR objectstack-ai#9061`) were `#`-spelled, so the
instrument saw them and they are gone. The only `#` left in these 26
files is on `//` comment lines (sibling card's surface), including a
`Prime Directive objectstack-ai#13` reference.
- **A citation whose page says something narrower than the text.**
`kernel-health-check-and-hot-reload-durations-unit-in-key` called
`shutdownTimeout`'s shape "the objectstack-ai#14519 unit-nowhere shape". `objectstack-ai#14519`'s
keys carried their unit in the JSDoc; "unit nowhere" is the gate's name
for it (`check-duration-unit-keys.ts` header: "no unit ANYWHERE (the
objectstack-ai#14519 shape)"), because the gate did not read JSDoc. The sentence now
says what the shape is — no unit in the name or in the published
describe — and that it was first measured on two tenant timeouts.
- **A comment that my text edit makes slightly stale.**
`18.system-metrics-window-durations-unit-in-key.ts` carries a `//`
comment saying its acceptanceCriteria sentence "is objectstack-ai#15679's, left word
for word". That sentence now says "that JSDoc-channel gap is filed as a
finding of its own" where it said "is objectstack-ai#15939": same content, no number.
The comment is the sibling card's surface (comment lines), so it is
untouched here.
- **Three "card" references re-anchored.** Removing an id left "the same
card" in the Turso entry pointing at nothing; it now says "the same
measurement". The kernel entries' "renamed by this same card" carry no
number and were not otherwise rewritten, so they are left.
- **Cross-PR check: no open PR adds or edits a `driver-`, `kernel-` or
`system-` semantic entry.** Read at 2026-09-28T04:0xZ: the 18 open PRs'
file lists (`GET /pulls/{n}/files`) carry 0 files matching
`migrations/entries/semantic/NN.(driver|kernel|system)-*`. The Version
Packages PR (`objectstack-ai#17076`) lists more than 1,000 files; the 1,100 rows read
carry no entry file, and it is the bot-generated release PR. Nothing in
flight will be held by the widened pin on arrival.
- **`main` moved 4 commits past the base** (`862b6ce869`: `objectstack-ai#20364`,
`objectstack-ai#20341`, `objectstack-ai#20366`, `objectstack-ai#20352`); none touches
`packages/spec/src/migrations/` or the pin. A driver-free bare-clone
`merge-tree --write-tree` of this head against `862b6ce869` exits 0 with
no conflicted path, so `registry.ts` needs no merge, and `main` was not
merged in.
- **Generated projections** (`spec-changes.json`,
`docs/protocol-upgrade-guide.md`) are regenerated, as in stages 1 and 2;
their `--check` legs are green. Only the three `driver-` entries
registered at protocol 17 appear in them, which is why those diffs are
small.
- **No other test pins these entries' text.** A `git grep` of test files
for the 26 entry ids finds one (`preview-mode-retirement.test.ts`),
which names the entry in a comment and reads no prose; a grep of tests
for the 37 cited numbers finds only comment lines. So no test needed
re-pinning this stage (stage 2's `migrations.test.ts` case has no
counterpart here).

## Line budget

Entry files: **352 changed lines** (+228 / −124) across 26 files,
against the stage-1 ≈400 budget. The whole diff is **776 lines** (+516 /
−260) in 31 files. Of the rest, `registry.ts` is 352, the two
projections are 18 (`spec-changes.json` 12, the upgrade guide 6), the
widened pin is 33 and the changeset is 21.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

spec: tenant.zod.ts idleTimeout / sessionTimeout publish a describe with no unit, while the JSDoc one line above says seconds

4 participants