Skip to content

feat(spec)!: the last seven data/ ui/ ai/ integration/ duration keys carry their unit in the key name — the gate reaches zero offenders (#15680) - #15988

Merged
os-zhuang merged 7 commits into
claude/issue-15679-system-duration-unit-in-key-namefrom
claude/issue-15680-data-ui-ai-integration-duration-unit-in-key-name
Sep 5, 2026
Merged

feat(spec)!: the last seven data/ ui/ ai/ integration/ duration keys carry their unit in the key name — the gate reaches zero offenders (#15680)#15988
os-zhuang merged 7 commits into
claude/issue-15679-system-duration-unit-in-key-namefrom
claude/issue-15680-data-ui-ai-integration-duration-unit-in-key-name

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Part of #14478
Stack card 5/6 — issue #15680, implemented here and deliberately left open (card 6/6 still owes the population widening).

Branch base: claude/issue-15679-system-duration-unit-in-key-name @ 45a35896c8ae9f93aeecce3ab604f4a0b43f3f87 — card 4/6's head, not main. git merge-base --is-ancestor answered exit 0 against that branch before the first edit (a positive ancestry read is self-certifying on a shallow checkout; only a negative would have needed a control leg).


⭐ This is the card that turns the headline gate green

pnpm --filter @objectstack/spec check:duration-unit-keys, verbatim, exit code captured before any pipe:

BEFORE (on the base, 45a35896c) — EXIT=1:

✗ check:duration-unit-keys — 7 offender(s) among 215 duration-shaped numeric key(s) in 828 source file(s) (6 declared `EpochMs` instant(s), 11 declared `externalVocabulary` mirror(s)):

AFTER (on 3351a860d) — EXIT=0:

✓ check:duration-unit-keys — 215 duration-shaped numeric key(s) across 838 source file(s) all carry their unit in the key name (or in a sibling `unit`, or under a declared exemption: 6 declared `EpochMs` instant(s), 11 declared `externalVocabulary` mirror(s)); zero offenders, no baseline.

The census is unchanged at 215 — the renamed keys stay in the population and are now compliant. The file count moves 828 → 838: ten new migration-entry files.

⚠️ This is green for the gate's currently declared population (packages/spec/src/**), not for the epic. Card 6/6 widens that population and #15642 has already measured an offender outside this subtree (packages/drivers/driver-turso/src/spec/turso.zod.ts:104). The gate is expected to go red again by design. #14478 is not finished by this PR.

The 7 renames — re-derived from the gate on this branch's own head before editing

site on the base key replacement unit route
src/ai/conversation.zod.ts:308 duration durationSeconds seconds semantic
src/data/driver-nosql.zod.ts:310 timeout timeoutMs ms semantic
src/data/driver/memory.zod.ts:112 autoSaveInterval autoSaveIntervalMs ms D2
src/data/driver/turso.zod.ts:215 timeout timeoutMs ms D2
src/integration/connector.zod.ts:541 monitoringWindow monitoringWindowMs ms D2
src/integration/connector.zod.ts:675 interval intervalSeconds seconds D2
src/ui/dashboard.zod.ts:906 refreshInterval refreshIntervalSeconds seconds D2

Row for row with the card's table. ⛔ packages/drivers/driver-turso/src/spec/turso.zod.ts:104 — the driver's own parallel schema — is not touched; card 6/6 owns it, so the two declarations disagree by design until that lands.

Every rename was an anchored substitution with abort-on-count-mismatch: each edit asserted the exact occurrence count of its anchor and refused the whole file otherwise.

⚠️ An eighth key moves that the gate did not list — declared, not slipped in

AutoPersistenceConfig.autoSaveInterval (src/data/driver/memory.zod.ts:189 on the base) is not a gate offender: its .describe() named no unit at all, so the predicate never judged it.

It moves anyway, and the boundary scan is this: persistence: { type: 'auto' } resolves to the same Node.js file adapter as type: 'file', and this value is forwarded to the same FileSystemPersistenceAdapter constructor field, in the same milliseconds, under the same min(100) bound, straight into the same setInterval. Renaming one arm and not the other would have left one value with two spellings across sibling arms of one union, with packages/drivers/driver-memory reading both — the consumer-side dialect Prime Directive #12 forbids. Its .describe() now names the unit too, so name and prose agree.

A pin asserts the refusal on the arm the gate never listed, so a later reader cannot "restore" the bare spelling as an over-application of the rule. If the PM would rather this were a separate card, it is one commit to lift out.

Dispositions — decided from stack.zod.ts, not defaulted

Four D2 conversions, because dashboards:, connectors: and datasources: are each declared as a stack collection in stack.zod.ts and their members are stored whole as sys_metadata rows:

  • dashboard-refresh-interval-to-refresh-interval-seconds
  • connector-health-and-trigger-durations-unit-in-key — both connector keys in one pass, emitting separately
  • memory-persistence-auto-save-interval-to-ms — both persistence arms
  • turso-config-timeout-to-timeout-ms

Both datasource conversions are driver-aware, for the reason datasource-config-driver-key-aliases records: a bare config.timeout under another driver is that driver's own key and must not be rewritten. Each fixture carries a member that never authored the key, so copy-on-write identity is pinned too.

Two semantic entries, because neither surface is a collection member or a stored row and the chain has no seam that would ever see one:

  • ai-conversation-analytics-duration-unit-in-key — analytics are computed at runtime and handed to a consumer
  • data-nosql-query-options-timeout-unit-in-key — a per-call driver argument, reached only through AggregationPipeline.options

All eight keys are retiredKey() tombstones with a RETIRED_KEYS_BY_MAJOR row. ⛔ No internal issue id appears in any prescription string — check:doc-authoring reads 0 findings across 15070 customer-facing strings and 46 published skill files; the ids live in // and /** */ comments only.

dashboard's three rename-hint aliases — refresh, autoRefresh, pollInterval — were repointed to refreshIntervalSeconds in the same edit. A hint left naming the tombstone would prescribe a key the shape refuses; a pin asserts all three, in both directions.

⚠️ objectui reader sites — measured, and they are real

dashboard.refreshInterval is the only rename in this whole stack whose consumer lives in another repository, so its reader could not move in this PR the way every other reader here did.

In-repo evidencesdui.manifest.json is tracked at the repo root (last written by 6b66ec749) and is objectui's own registry-inputs dump, produced by driving a real browser over objectui built at the .objectui-sha pin:

  • sdui.manifest.json:805 — component dashboard (namespace view) declares an input named refreshInterval, described "Auto-refresh period in seconds. Zero or a negative value disables the timer, and it only runs when the host wires an onRefresh handler."

⚠️ check:react-declaration-parity does not cover this. Run with MANIFEST pointed at the committed dump it exits 0 and reports 90 spec-only divergences, 0 blocks missing from the registry — its scope is ObjectForm / ListView / ObjectChart plus the SDUI object-* blocks, and the dashboard component is in none of them. So this divergence is invisible to that gate in both directions.

Reader sites at the exact pin — read from the sibling objectui checkout at a472b07167a39e55491109e864bb5a54027dcfbd, which is the sha .objectui-sha names (that checkout's own HEAD is 1ec291c0d, so every path below was read at the pinned commit, not at its tip). ⛔ Nothing in objectui was modified.

Runtime readers — these are the ones that break:

  • packages/plugin-dashboard/src/DashboardRenderer.tsx:448-453schema.refreshInterval, guarded <= 0, then setInterval(handleRefresh, schema.refreshInterval * 1000)
  • packages/plugin-dashboard/src/DashboardGridLayout.tsx:146-151 — the same read and the same * 1000

Declaration / registry (this is what produces sdui.manifest.json:805):

  • packages/plugin-dashboard/src/index.tsx:94 — the published input declaration
  • packages/plugin-dashboard/src/DashboardConfigPanel.tsx:108 — the designer config-panel field key

objectui's own parallel type declarations:

  • packages/types/src/zod/complex.zod.ts:740refreshInterval: z.number().min(0).optional().describe('Auto-refresh interval in seconds')
  • packages/types/src/complex.ts:1132, packages/types/src/designer.ts:585, packages/types/src/data.ts:1358, packages/types/src/ai.ts:453

i18n:

  • packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts:91 — the zh-CN label keyed by the field name

Pins that will go red against a bumped spec:

  • packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts:168-170 — asserts Dashboard declares refreshInterval; this is the explicit spec-parity pin
  • packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx:159,170,221,333,353 — including expect(block).toContain('refreshInterval?: number;')
  • packages/types/src/__tests__/dashboard-config.test.ts:37,122
  • packages/plugin-dashboard/src/__tests__/ConfigPanel.i18nWiring.test.tsx:242

Docs:

  • content/docs/plugins/plugin-dashboard.mdx:54, content/docs/core/report-schema.mdx:392, content/docs/api/schema-reference.md:941,987

⚠️ One incidental finding for whoever takes the follow-up: content/docs/api/schema-reference.md:987 documents the key as "Auto-refresh interval in milliseconds" while every other objectui site and the spec say seconds. That is an objectui-side doc error that predates this PR — noted, not touched.

Not a reader of this key: packages/plugin-report/src/ReportViewer.tsx:230 reads report.refreshInterval, which is objectui's own report schema (packages/types/src/reports.zod.ts:167), a different declaration.

Behaviour until the follow-up lands: the renderer sees an absent key and does not start its refresh timer. A dashboard still renders and still refreshes on demand. The D2 conversion in this PR is what keeps stored dashboards and os migrate meta correct in the meantime.

A retirement tombstone is no longer read as a secret

refusedCredentialKeys derives a driver's refused inline credentials by finding z.never() keys in its config contract. A retiredKey() tombstone is also a z.never(), and until this card no driver contract carried one — so "never implies credential" held by accident of population rather than by construction. The first tombstone to arrive (TursoConfig.timeout) made the derivation answer that a millisecond budget was a secret: three pins in datasource-credential-redaction.test.ts went red, refusedCredentialKeys('turso') returned ['authToken', 'timeout'], and timeout was redacted off the datasource read path.

Measured before choosing the fix — every genuine credential slot in every builtin driver contract (postgres, mysql, mongodb, turso) carries format: 'password'; the tombstone was the only z.never() that did not.

The derivation now skips keys carrying the [REMOVED] prefix retiredKey() itself stamps. The exclusion is deliberately negative — skip declared tombstones — rather than positive (keep only keys marked format: 'password'), even though the positive form would work today: under-redacting is the dangerous direction, so a future credential key whose author forgets the marker is still scrubbed, and only a key that has explicitly declared itself retired may drop out. Both directions are pinned, and the pin also asserts the tombstone really is in the shape — without that leg it would pass equally on a key deleted outright.

Reverse verification — two ablation legs, prediction stated before running

No build is involved in either leg and none was needed: the gate is a source scanner, the vitest suites import ./…zod by relative source path, and the probe imported the source file directly. There is no dist/ resolution in play, so no stale-artifact hazard applies. Each leg restored with git checkout HEAD -- ABSOLUTE_PATH under a trap … EXIT INT TERM, with the restore proved by git diff HEAD empty and a blob-hash match — never by an exit code.

Leg A — revert one rename (the dashboard key), keep everything else.
Predicted: gate RED, exit 1, exactly one offender, [unit-in-prose-not-in-name] src/ui/dashboard.zod.ts refreshInterval, census still 215.
Observed: exactly that.

mutated blob : 758a16a238b235f333bc9cf5083ec3a03bb43d11   (HEAD blob 81ad8eadcef589f381a6db5a37efcf34859f71a9)
injected `refreshInterval: z.number()` count : 1     deleted `refreshIntervalSeconds:` count : 0     tombstone count : 0
GATE_EXIT=1
✗ check:duration-unit-keys — 1 offender(s) among 215 duration-shaped numeric key(s) in 838 source file(s) …
  [unit-in-prose-not-in-name] src/ui/dashboard.zod.ts:906 `refreshInterval` — describe names seconds but the key name carries no unit.
restored blob 81ad8eadcef589f381a6db5a37efcf34859f71a9 == HEAD blob;  git diff HEAD empty

Leg B — card 4/6's model: keep the rename, delete ONLY the tombstone, on the non-strict CircuitBreakerConfig so the silent strip is demonstrable.
Predicted: (1) the headline gate stays GREEN and exit 0 — it judges the canonical key's name against its prose and is structurally blind to a missing tombstone; (2) the refusal pin goes RED; (3) the parse succeeds and the authored value vanishes, falling back to the 60000 default; (4) check:authorable-surface goes RED as the [RETIRED] row disappears.
Observed: all four.

deleted `monitoringWindow: retiredKey(` count : 0     kept `monitoringWindowMs: z.number()` : 1
mutated blob : e3d3e7551b7a8b7e502c55ddf26bd4055f366230   (HEAD blob 6e83add4c04a2018151a1c264f9bb2e7e688043c)

1. GATE_EXIT=0    ✓ check:duration-unit-keys — … zero offenders, no baseline.        ← blind, as predicted
2. PIN_EXIT=1     FAIL src/integration/connector.test.ts > REFUSES the retired `monitoringWindow` …
                  Tests  1 failed | 66 passed (67)
3. parse succeeded      : true
   monitoringWindow kept: false
   monitoringWindowMs   : 60000        ← the author asked for 120000 and got the default, with no error anywhere
4. SURFACE_EXIT=1  - integration/CircuitBreakerConfig:monitoringWindow

restored blob 6e83add4c04a2018151a1c264f9bb2e7e688043c == HEAD blob;  git diff HEAD empty

Leg B is the whole argument for tombstones on non-strict shapes: the headline gate cannot see the defect, and the only thing standing between an author and a silently halved monitoring window is the refusal pin.

Verification — union run AFTER the final commit, at 3351a860d

command exit
pnpm --filter @objectstack/spec test 0Test Files 473 passed (473), Tests 12844 passed (12844)
pnpm --filter @objectstack/spec typecheck 0
pnpm --filter @objectstack/spec check:duration-unit-keys 0 — the verdict line quoted above
pnpm --filter @objectstack/spec check:generated 0All 15 generated artifacts are up to date
pnpm --filter @objectstack/spec check:skill-examples 0257 prose examples type-check across 3 surface(s)
pnpm --filter @objectstack/spec check:liveness 0
pnpm --filter @objectstack/spec check:empty-state 0
pnpm check:doc-authoring 0 — 0 findings
pnpm check:i18n 09 package(s) — all bundles in sync
pnpm check:i18n-stale-fill 0
pnpm check:nul-bytes 0 — 7704 files scanned
pnpm check:pm-skill-ratchet 0
node scripts/check-skills-token-ratchet.mjs 0
node scripts/check-adr-0087-registration.mjs --base origin/main 0 — 8 declared-breaking changesets, each with a disposition
node scripts/check-changeset-no-major.mjs, check-empty-changeset.mjs, pnpm check:changeset-gate-self-tests 0
pnpm check:stack-collection-maps · check:spec-parsed-alias · check:dispatcher-error-vocabulary · check:error-code-casing 0
pnpm check:skill-frame-sync · check:skill-compatibility · check:skill-identifier-liveness 0
pnpm check:docs-single-h1 · check:doc-anchors · check:cross-package-test-inputs · check:test-source-alias 0
node scripts/check-published-list-mirrors.mjs 0
pnpm --filter @objectstack/driver-memory test + typecheck 0Test Files 41 passed, Tests 1058 passed
pnpm --filter @objectstack/cli typecheck 0

check:generated was re-run after the final rebuild: its first run reported check:api-surface stale, and the gate's own text named the cause — dist/**/*.d.ts older than src. Rebuilt, re-ran, green. No artifact was regenerated on that evidence.

Declared narrowing. pnpm lint (the whole-repo eslint sweep) was not run here; CI runs the farm exactly once on this diff. Everything above is the cheap half.

⚠️ pnpm check:published-list-mirrors returns 254 — command not found, a NON-READING, not a pass and not a failure. The script is node scripts/check-published-list-mirrors.mjs, which exits 0. Likewise the first node scripts/check-i18n-bundles.mjs --write returned exit 3 — PREREQUISITE NOT MET (the CLI closure was not built); the reading below is from the run after that closure was built.

Every exit code above was captured with cmd > file 2>&1; EXIT=$? — redirect first, then read $?. Nothing was read through a pipe.

⛔ Inherited reds — NOT from this PR

Both were red on the base and are proven inherited by blob identity, not by argument:

  • pnpm --filter @objectstack/spec check:llms-txt — 2 findings, both counting src/shared/. packages/spec/llms.txt (74bba77c…), the gate script (916a9990…) and the src/shared/ tree (3c002da6…) are byte-identical between 45a35896c and 3351a860d, and the population the gate counts — *.zod.ts files under packages/spec/src/ — is 209 on both sides. My ten new files are migration entries, not schemas.
  • pnpm check:quick-reference-counts — 1 finding, [total] section "Shared Protocol" declares "of 8 schemas" but content/docs/references/shared/ publishes 9. quick-reference.mdx (f5488e77…), the content/docs/references/shared tree (e8c0bcc9…) and the gate script (ce2b2de3…) are all byte-identical between base and head.

Already fixed on card 1/6's branch. ⛔ Not repaired here.

skills/** readings — this PR is governed, and the file stays

One published skill file is touched, so the PR is governed and the file is deliberately kept rather than dropped to escape governance.

reading before after net
skills/objectstack-ui/rules/dashboards.md — lines 462 462 0
skills/objectstack-ui/rules/dashboards.md — tokens (gate convention, ceil(utf8 bytes / 4)) 6090 6089 −1
whole published bundle — lines (every tracked file under skills/) 13655 13655 0

check-skills-token-ratchet confirms it independently: skills/objectstack-ui/rules/dashboards.md is 6089 tokens (ceiling 6090; headroom 1). No content was bought — the change is one key name and a shortened trailing comment.

Out-of-scope

Nothing was filed. The one thing that looked like a candidate — AutoPersistenceConfig.autoSaveInterval — is handled inline above with its boundary scan, because leaving it would have shipped one value under two spellings.

api/, kernel/, system/, shared/, the gate's declared population, other PRs' diffs and content/docs/releases/ are untouched.


Generated by Claude Code

…bstones on the old spellings (#15680)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…for the 7 renames (#15680)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…al derivation so a retirement tombstone is not read as a secret (#15680)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…ey rename, translations carried over (#15680)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…s, plus the auto-arm sibling (#15680)

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

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cli, @objectstack/driver-memory, @objectstack/platform-objects, @objectstack/service-datasource, @objectstack/spec, touching 53 documentable anchor(s). ⚠️ 16 changed file(s) yielded no anchor (packages/spec/authorable-defaults/data.json, packages/spec/authorable-defaults/integration.json, packages/spec/authorable-surface/ai.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/connectors.mdx (via billing_api (literal, a string literal in fixture))
  • content/docs/automation/flows.mdx (via timeoutMs (literal, a string literal in apply))
  • content/docs/automation/hook-bodies.mdx (via timeoutMs (literal, a string literal in apply))
  • content/docs/automation/jobs.mdx (via timeoutMs (literal, a string literal in apply))
  • content/docs/automation/webhooks.mdx (via timeoutMs (literal, a string literal in apply))
  • content/docs/deployment/environment-variables.mdx (via timeoutMs (literal, a string literal in apply))
  • content/docs/getting-started/quick-reference.mdx (via DashboardSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/metadata-service.mdx (via sales_overview (literal, a string literal in fixture))
  • content/docs/protocol/kernel/i18n-standard.mdx (via sales_overview (literal, a string literal in fixture))
  • content/docs/protocol/objectui/actions.mdx (via sales_overview (literal, a string literal in fixture))
  • content/docs/protocol/objectui/concept.mdx (via sales_overview (literal, a string literal in fixture))
  • content/docs/protocol/objectui/index.mdx (via sales_overview (literal, a string literal in fixture))
  • content/docs/ui/apps.mdx (via sales_overview (literal, a string literal in fixture))
  • content/docs/ui/dashboards.mdx (via refreshIntervalSeconds (literal, a string literal in DashboardSchema; a string literal in apply; a string literal in dashboard; a string literal in sections; a string literal in summary), sales_overview (literal, a string literal in fixture))

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

  • content/docs/releases/v15.mdx (via DashboardSchema (symbol, a top-level const))
  • content/docs/releases/v16.mdx (via timeoutMs (literal, a string literal in apply), os explain (command, read off packages/cli/src/commands/explain.ts))
  • content/docs/releases/v17.mdx (via ConnectorTriggerSchema (symbol, a top-level const), timeoutMs (literal, a string literal in apply))

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

What this run could not see
  • 16 changed file(s) yielded no anchor (packages/spec/authorable-defaults/data.json, packages/spec/authorable-defaults/integration.json, packages/spec/authorable-surface/ai.json, …) — pages documenting those are invisible to this run
  • 13 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 — 136 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 45a35896c8ae9f93aeecce3ab604f4a0b43f3f87packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9cd53009156bdc29e68140f04a8f95675e1db599 — the merge of head 808691883023a04d844f5360bfaae7361a01b61f into base 45a35896c8ae9f93aeecce3ab604f4a0b43f3f87, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older claude/issue-15679-system-duration-unit-in-key-name holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9cd53009156bdc29e68140f04a8f95675e1db599 && git checkout 9cd53009156bdc29e68140f04a8f95675e1db599
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45a35896c8ae9f93aeecce3ab604f4a0b43f3f87 808691883023a04d844f5360bfaae7361a01b61f && git checkout -B drift-repro 45a35896c8ae9f93aeecce3ab604f4a0b43f3f87 && git merge --no-ff 808691883023a04d844f5360bfaae7361a01b61f

node scripts/docs-audit/affected-docs.mjs --json 45a35896c8ae9f93aeecce3ab604f4a0b43f3f87

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

os-sales commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Seat ruling on both open questions — A and A

⭐ First, the headline this card earns: check:duration-unit-keys reads exit 0 for the first time in the campaign — 215 duration-shaped numeric key(s) across 838 source file(s) all carry their unit in the key name … zero offenders, no baseline, census unchanged at 215. ⛔ Green for the declared population packages/spec/src/** only; card 6/6 widens it and #15642 has already measured an offender outside, so the epic is not done.

Q1 — the eighth key (AutoPersistenceConfig.autoSaveInterval): keep it here

Verified on this head rather than taken on report:

  • FilePersistenceConfigSchema (type: 'file') → autoSaveIntervalMs: z.number().min(100).default(2000)
  • AutoPersistenceConfigSchema (type: 'auto') → autoSaveIntervalMs: z.number().min(100).optional()
  • memory-driver.ts:2294 and :2302 both construct new FileSystemPersistenceAdapter({ path, autoSaveIntervalMs: persistence.autoSaveIntervalMs }) — same adapter, same field, same value, one indirection apart.

⚠️ The reason the gate did not list this key is worth stating, because it inverts the intuition: its .describe() named no unit at all, so the predicate never judged it. That is the #14519 "unit nowhere" shape — worse than the offenders the gate did list, not better. Being invisible to the predicate is not being correct.

So option B does not buy smaller scope; it buys one value under two spellings across sibling arms of one union, with the driver reading both — the exact ambiguity ruling B exists to remove, reintroduced into the union this card just cleaned. Option C is worse still: a key named *Ms whose prose names no unit is a census row that now looks compliant, which is harder to catch later, not easier.

A. The rename stays in this PR. It is declared in the body with its boundary scan and pinned, which is what makes it reviewable rather than smuggled.

Q2 — the credential-derivation narrowing: negative exclusion, and it goes to contract review as a decision, not an inheritance

C is plainly wrong and the dev said so — adding timeout to CANONICAL_CREDENTIAL_KEYS would redact a key named timeout on every driver, including ones with nothing to do with this card.

Between A and B the dev framed the discriminator correctly — which way the mistake falls. B fails toward under-redaction (a credential served in cleartext because an author forgot a marker); A fails toward over-redaction (a retired key redacted for one release). For a security-adjacent default, fail closed. That settles it.

⭐ But there is a stronger argument the report did not quite make, and it is the one I want on the record:

B would repeat the exact mistake that produced this bug.

The original derivation — z.never() ⇒ credential — was true by accident of population: no driver contract happened to carry a tombstone, so nothing falsified it, until this card put the first one in and the derivation answered that a millisecond budget was a secret. B is also true by accident of population: it is green today only because every builtin credential slot happens to carry format: 'password' today. Adopting it would swap one population-contingent invariant for another and wait for the next card to falsify it.

A is true by construction: a key drops out only by explicitly declaring itself retired, using the [REMOVED] prefix retiredKey() itself stamps — the producer's own marker, already read by the reference pages and the authorable-surface ratchet. No new marker invented, and the derivation no longer depends on what the population happens to contain.

A, and the dev's instinct to escalate rather than let it inherit is right. This PR already carries needs:contract-review; this specific call is named for that review — a security-relevant default changed under a rename card, decided on failure-direction asymmetry. ⛔ The reviewer should confirm it rather than read past it.

Scope, for the record

The edit is forced, not creep: without it, this card's own rename introduces a security-behaviour regression — a duration budget redacted off the read path and dragged into the unknown-driver fallback list. "Move every reader in the same PR at the same magnitude" applies here to a derived behaviour rather than a literal reader, and both directions are pinned.

⛔ Draft, not enqueued, no auto-merge, not approved. ⚠️ This PR is governed (skills/objectstack-ui/rules/dashboards.md), so it needs a human merge like cards 1/6 and 2/6 — unlike 3/6 and 4/6, whose predicate exits 0.


Generated by Claude Code

…at the shared libSQL seam

`buildTursoDriverConfig` still consulted `config.timeout` after that authored
key was renamed to `timeoutMs` and tombstoned, so a datasource authored the
canonical way had its timeout silently dropped. `TursoConfigSource.config` is an
untyped string-keyed bag, so tsc could not see the rename through it, and the
covering test authored the retired spelling at all three of its sites and stayed
green over the defect.

The reader now reads `config.timeoutMs`; the driver key it lands on stays
`timeout` (published-but-inert, must not be ratified by a rename). No fallback
arm for the retired spelling — the sqlite `filename` and mongo `url` arms in
`default-datasource-driver-factory.ts` set that precedent, and both authoring
and stored-row rehydration deliver the canonical key already.

The covering test moves to the canonical spelling and gains contract-derived
cases that read the schema's own tombstones, so they hold for the next rename
without being edited. The two sibling pins that author the same spec move with
it.

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

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up fix on this branch: the libSQL seam now reads the canonical config.timeoutMs (#16023)

Commit 808691883. Five files, no new PR, nothing outside the sweep below.

buildTursoDriverConfig — the ONE seam both libSQL loaders go through (#7314) — still
consulted config.timeout after this PR renamed that authored key to timeoutMs and
tombstoned the old spelling. A datasource authored the canonical way reached the seam,
matched nothing, and had its timeout silently dropped.

⭐ Reproduction, before and after

Run against the seam's source. The mutation leg reverted only
turso-driver-config.ts to this PR's previous head 3351a860db, proved on disk by blob
hash (ecdd75d0… == the base blob), restored under a trap to HEAD and proved
restored by blob identity (113d83dc…) plus an empty git diff HEAD.

BEFORE (reader consults config.timeout):

authored { url, timeoutMs: 9000 } -> built: {"url":"libsql://app.turso.io"}
  driver config .timeout = undefined          <-- the authored timeout is dropped
authored { url, timeout: 9000 }   -> built: {"url":"libsql://app.turso.io","timeout":9000}
  driver config .timeout = 9000               <-- only the spelling authoring now REFUSES works

AFTER (reader consults config.timeoutMs) — exactly inverted:

authored { url, timeoutMs: 9000 } -> built: {"url":"libsql://app.turso.io","timeout":9000}
  driver config .timeout = 9000
authored { url, timeout: 9000 }   -> built: {"url":"libsql://app.turso.io"}
  driver config .timeout = undefined

The DRIVER key stays spelled timeout. TursoDriverConfig.timeout is
published-but-inert (#16024) and renaming an inert key would ratify it as real, which is
what ADR-0049 exists to prevent. This seam is therefore the one place the authored and
driver spellings differ, and it now says so in a comment.

The decision: no fallback arm for the retired spelling

Not a new rule — the seam's own precedent, stated twice in
default-datasource-driver-factory.ts: sqlite's "filename is the whole contract … so
no ?? tolerance survives here", and mongo's "url is the one spelling". A renamed
datasource config key arrives already canonical from two directions:

  • authoring refuses the retired spelling at the door (retiredKey() — tsc never plus a
    parse-time prescription);
  • a stored sys_metadata row replays the FULL ADR-0087 chain, retiredFromLoadPath
    entries included, at loadDatasourceRows / loadDatasourceRow in
    datasource-admin-plugin.ts — so turso-config-timeout-to-timeout-ms has already
    rewritten the key before this table sees it.

A ?? arm would be a consumer-side dialect (Prime Directive #12) for a spelling both
doors just closed. authToken's legacy arm is not a counter-precedent: it is kept for a
LIVE route (host boot translating OS_DATABASE_AUTH_TOKEN into a config it constructs
itself, which never meets the authoring schema), not for a retired spelling.

The test that would have caught it

The covering test authored the retired spelling at all three of its turso config
sites, so it was green for exactly the behaviour that had become wrong. Those three move
to the canonical spelling, and the file gains cases DERIVED from the authoring contract
rather than written against today's key list — they read TursoConfigSchema's own
retiredKey() tombstones (the [REMOVED] prefix) and assert that every canonical
replacement IS consulted by some reader and no retired spelling is, probed at every JS
type a reader could type-test, behind a vacuity guard.

Run against the unfixed reader, 6 of 15 cases fail, including:

AssertionError: no reader consults the canonical `timeoutMs` (retired: `timeout`) —
  the rename moved the authoring contract and left this seam behind:
  expected [ 'libsql://x' ] to include 424242

AssertionError: a reader still consults the retired `timeout` (probed with number):
  expected [ 'url', 'timeout' ] to deeply equal [ 'url' ]

They hold for the NEXT rename without being edited. (The vacuity guard earned itself on
its first run: it caught that a tombstone is z.never(), so nothing parses against it and
my first probe would have been asking a tombstone for a valid sample.)

The two sibling pins that author the same spec — packages/cli's driver-correspondence
check and packages/runtime's cross-loader convergence check — move to the canonical
spelling with it. Their assertions read driver keys and are unchanged.

⭐ The sweep — all eight renamed keys, including the empty results

"Grep by TYPE, not by name" fails at an untyped seam by construction, so every key this
PR renamed was swept by NAME across every workspace package, for dotted reads on a bag,
bracket access, .get(...) with a string literal, in tests, destructuring, and
string-literal key lists. Excluded dist/, node_modules/ and CHANGELOGs.

renamed key untyped / string-keyed readers found
TursoConfig.timeout 1 — the defect. service-datasource/src/turso-driver-config.ts:170. Fixed here.
dashboard.refreshInterval 0 in this repo. The only reader is objectui's, already documented in this PR body and sequenced behind a release. In-repo sites (cli/src/commands/explain.ts:241, the four generated *.metadata-forms.generated.ts) already say refreshIntervalSeconds.
CircuitBreakerConfig.monitoringWindow 0 — no runtime reader at all. Outside packages/spec the name appears only in the generated authorable-surface / authorable-defaults artifacts. Declared-only surface.
ConnectorTrigger.interval 0. ConnectorTrigger is imported nowhere; the only .triggers read is connector.zod.ts:996's own superRefine, which reads the array, never interval.
FilePersistenceConfig.autoSaveInterval 0 stale. driver-memory reads autoSaveIntervalMs throughout (memory-driver.ts:2296,2304, persistence/file-adapter.ts:21,23,70) — moved with the rename in this PR.
AutoPersistenceConfig.autoSaveInterval 0 stale. Same adapter, same field, same call sites as the file arm.
NoSQLQueryOptions.timeout 0 — zero consumers anywhere. NoSQLQueryOptions appears only inside packages/spec. Nothing outside reads it, typed or untyped.
ConversationAnalytics.duration 0 — zero consumers anywhere. Same: ConversationAnalytics appears only inside packages/spec.

The measured empty results are the finding: seven of the eight renamed keys had no
untyped reader to leave behind, and TursoConfig.timeout was the only one with a live
string-keyed consumer. The near-misses that a name-grep surfaces and that are NOT this
class, each checked and dismissed: core/src/health-monitor.ts config.timeout /
config.interval (typed PluginHealthCheckParsed, and PluginHealthCheck is not among
the eight); plugin-email/src/transports/smtp.ts:149 this.opts.timeout (SMTP transport
options); cli/src/commands/* flags.timeout (CLI flags);
metadata-fs/src/repository.ts interval (its own watcher option);
core/src/qa/http-adapter.ts:226 action.payload?.duration and
service-automation's wait-node config.duration (neither key is one of the eight).

packages/drivers/driver-turso/src/spec/turso.zod.ts:104 — the driver package's own
parallel timeout — is not touched. It is card 6/6's, exactly as this PR body says.

One adjacent observation, NOT filed and NOT touched

cloud-connection/src/cloud-connection-plugin.ts:324 reads json.interval off the cloud
device-code response and re-emits it, while DeviceRequestResponse.interval was renamed
to intervalSeconds by card 2/6 (#15677). Different card's key, and it crosses a
network boundary to another repo, so it is not this sweep's to judge — flagging it for
whoever owns card 2/6's reader audit rather than filing over it.

Verification — union run at 808691883, the final commit

Every exit code was captured by redirecting stdout and stderr to a file FIRST and
reading $? on the next statement. Nothing was read through a pipe.

command exit
pnpm --filter @objectstack/spec check:duration-unit-keys 0 — unchanged census, verdict below
pnpm --filter @objectstack/spec test 0Test Files 473 passed (473), Tests 12844 passed (12844)
pnpm --filter @objectstack/spec typecheck 0
pnpm --filter @objectstack/service-datasource test 0Test Files 31 passed (31), Tests 647 passed (647)
pnpm --filter @objectstack/service-datasource typecheck 0
pnpm --filter @objectstack/cli exec vitest run src/utils/storage-driver.test.ts 0 — 36 passed
pnpm --filter @objectstack/runtime exec vitest run src/turso-driver-factory.convergence.test.ts 0 — 7 passed
pnpm --filter @objectstack/cli typecheck · @objectstack/runtime typecheck 0
pnpm --filter @objectstack/spec check:generated 0All 15 generated artifacts are up to date
pnpm check:doc-authoring 0 — 0 findings
pnpm check:nul-bytes 0 — 7709 files scanned
pnpm check:test-source-alias · check:cross-package-test-inputs 0

The required gate, verbatim:

✓ check:duration-unit-keys — 215 duration-shaped numeric key(s) across 838 source file(s)
  all carry their unit in the key name (or in a sibling `unit`, or under a declared
  exemption: 6 declared `EpochMs` instant(s), 11 declared `externalVocabulary` mirror(s));
  zero offenders, no baseline.

215 among 838, zero offenders, exit 0 — identical to this PR's own reading. A reader
fix did not move it, as required.

typecheck really does cover the edited test file: tsc --noEmit --listFiles lists
src/__tests__/turso-driver-config.test.ts (1 hit), so "typecheck is clean" is a
statement about the new cases and not a vacuous one.

Declared narrowing. The whole-repo pnpm lint sweep was not run. Instead
eslint --no-inline-config --format json ran on the 4 edited source files: 4 files
linted, 0 errors, 0 warnings
. That narrowing is a measurement, not a skip, because this
repo runs one eslint.config.mjs which — by its own header, measured with a positive
control — never enables type-aware linting for ANY file, so a 4-file diff cannot move the
verdict on an untouched file. CI runs the farm once regardless.

⚠️ Two NON-READINGS encountered and discarded rather than reported as results:
pnpm check:generated at the repo root exits 254 — command not found (it is a
packages/spec script; scoped correctly it exits 0), and the first reproduction run
exited 1 with ERR_MODULE_NOT_FOUND on a relative import path, which is a broken
invocation and not a red.

Inherited red, independently re-confirmed — still not repaired here

pnpm check:quick-reference-counts exits 1: [total] section "Shared Protocol" declares "of 8 schemas" but content/docs/references/shared/ publishes 9. This PR body
already records it as inherited. Re-proved from my side rather than taken on trust: my
commit changes 0 files under content/docs/ (git diff 3351a860db..HEAD --name-only -- content/docs/ is empty), and content/docs/references/shared/epoch.mdx is added by
this branch relative to origin/main, which is what takes that category from 8 to 9.
Pre-existing, out of my scope, and flagged to the PM as an open blocker on this PR.


Generated by Claude Code

os-sales commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Card 5/6 verified — and the "inherited blocker" recorded on this PR is measured to be no blocker

os-sales / session_01G4138K1EG7kQ81FNba5Kp4, 2026-09-05T20:43Z, on head 808691883. Independent enumeration, not a read-back of the follow-up report. Draft flag cleared.

1. The seam fix — read, not relayed

packages/services/service-datasource/src/turso-driver-config.ts now reads config.timeoutMs; the DRIVER key stays spelled timeout; there is no ?? arm for the retired spelling. Five files: the reader, its covering test, the two sibling pins in packages/cli and packages/runtime, and a changeset.

The gate's census could not have moved, and that is structural rather than a matter of trust: none of the five files sits under packages/spec/src/, which is the subtree check:duration-unit-keys parses. So 215 duration-shaped keys across 838 source files, zero offenders is unmoved by construction — a reader fix that shifted it would itself have been the finding.

2. The renamed-key list, derived independently

Not taken from the report — enumerated from the ADR-0087 retired-key entries this card adds:

18.ai__ConversationAnalytics__duration                18.data__NoSQLQueryOptions__timeout
18.data__AutoPersistenceConfig__autoSaveInterval      18.data__TursoConfig__timeout
18.data__FilePersistenceConfig__autoSaveInterval      18.integration__CircuitBreakerConfig__monitoringWindow
18.ui__Dashboard__refreshInterval                     18.integration__ConnectorTrigger__interval

Eight entries, matching the sweep's eight rows exactly. Independently confirmed that four of the owning types (NoSQLQueryOptions, ConversationAnalytics, ConnectorTrigger, CircuitBreakerConfig) and both persistence config types occur zero times anywhere outside packages/spec.

3. The sweep re-run independently — same two live sites, no third

Re-swept every retired spelling repo-wide by name, by bag-shaped read and by string literal. It reproduces the table, and turns up nothing the report missed. The near-misses land the same way: core/src/health-monitor.ts's config.timeout is typed PluginHealthCheckParsed (not one of the eight), driver-turso's own config.timeout is card 6/6's, and packages/runtime/README.md:411 is a self-contained ApiPlugin example with its own local config schema.

Two sites outside the eight, both already owned:

  • packages/cloud-connection/src/cloud-connection-plugin.ts:324 — card 2/6's key. Audited and dismissed on feat(spec)!: the twelve api/ duration keys carry their unit in the key name (#15677, stack 2/6) #15837 (comment 5554671428): the whole block speaks RFC 8628's snake_case wire vocabulary, so the rename cannot reach it.
  • sdui.manifest.json:805"name": "refreshInterval" in objectui's checked-in manifest, which is regenerated only when .objectui-sha moves; the objectui-side rename is tracked at objectui#7783.

That second one is worth a measurement rather than a shrug, because the gate reading it lives in a required context. Lint & Repo Gates runs check:react-declaration-parity --strict against that manifest. It cannot fire on this rename: the gate builds its population from REACT_BLOCKS plus

const SDUI_OBJECT_BLOCK_TYPES = Object.keys(ComponentPropsMap)
  .filter((t) => t.startsWith('object-'))

and dashboard is neither a REACT_BLOCKS tag nor object--prefixed. The checked-in baseline confirms the population is exactly nine blocks — ObjectForm, ListView, ObjectChart and six object-* types — with no dashboard entry. So the manifest's stale prop is outside the comparison, not merely accepted by it.

4. ⭐ The check:quick-reference-counts red is inherited AND self-clearing — it blocks nothing

The follow-up report flags it as "still blocks #15988". Measured, it does not: card 1/6 fixed those counts in a lap that reached the base branch after this chain forked, and no card in the chain touches those files, so the merge takes the base's side.

tree quick-reference.mdx:209 llms.txt shared row llms.txt heading
base branch tip c09fd1023 (5 of 9 schemas) | shared | 14 | (208 schemas)
this head 808691883 (5 of 8 schemas) | shared | 13 | (207 schemas)
git merge-tree of this head into the base branch (5 of 9 schemas) | shared | 14 | (208 schemas)

The third row is the one that decides it, and it is the tree that will actually exist. ⛔ So this is not to be repaired on this card — repairing it here would conflict with the fix already on the base branch.

Verdict

Verified. This card touches skills/objectstack-ui/rules/dashboards.md, so it is governed surface ⇒ human merge, after card 4/6 lands. Merge into the base branch is measured clean in both the driver-enabled and driver-disabled readings, which agree byte for byte.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Contract review (clause ②) PASS at head 80869188 — director seat, session_01TezFG8ZMrNH6n5VTNpPpdH, 2026-09-05T23:1xZ. Verdict (7 + 1 renames, four D2 + two semantic, the objectui reader tracked at objectui#7783, the negative tombstone exclusion in the credential derivation confirmed as fail-closed toward over-redaction, the #16023 seam fix): objectstack#15680 comment 5555255839. needs:contract-review removed from this PR and the card in the same stroke.

Governed (skills/objectstack-ui/rules/dashboards.md, 1 of 60): human merge; no ready flip / auto-merge from any seat. Lands in stack order under the domain:spec seat.

Implemented-by: session_01G4138K1EG7kQ81FNba5Kp4
Reviewed-by: session_01TezFG8ZMrNH6n5VTNpPpdH


Generated by Claude Code

@os-zhuang
os-zhuang merged commit bcf0412 into claude/issue-15679-system-duration-unit-in-key-name Sep 5, 2026
12 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-15680-data-ui-ai-integration-duration-unit-in-key-name branch September 5, 2026 23:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants