Skip to content

fix(spec)!: TimeUpdateInterval retires its three sub-day intervals and derives its members from DateGranularity (#17296) - #17893

Merged
os-bill merged 4 commits into
mainfrom
claude/issue-17296-timeupdateinterval-determination
Sep 12, 2026
Merged

fix(spec)!: TimeUpdateInterval retires its three sub-day intervals and derives its members from DateGranularity (#17296)#17893
os-bill merged 4 commits into
mainfrom
claude/issue-17296-timeupdateinterval-determination

Conversation

@os-bill

@os-bill os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17296

Clause-②: yes

The determination, per member

second, minute and hour are residue, and the same reading answers all
three — they are not one planned and two residue. The reason is not that the
backends lag the contract; the rest of the contract never carried them.

layer declares measured from
TimeUpdateInterval (packages/spec/src/data/analytics.zod.ts:53 before, :134 after) 8 TimeUpdateInterval.options
DateGranularity (packages/spec/src/data/query.zod.ts:184) — what QueryAST.groupBy[].dateGranularity and every driver bucket expression are typed by 5 DateGranularity.options
@objectstack/core's BUCKET_GRANULARITIES (packages/core/src/utils/datetime.ts:215) — the canonical bucket-KEY output contract a drill-down crosses 5 the exported tuple
driver-mongodb's MONGODB_DATE_GRANULARITIES 5 Object.keys(...)
driver-sql's dateGranularityCapabilities (packages/drivers/driver-sql/src/sql-driver.ts:5151) 5 per dialect, 4 on SQLite source, typed 'day' | 'week' | 'month' | 'quarter' | 'year'

The decisive one is the capability mechanism itself.
DriverCapabilitiesSchema.supports.queryDateGranularity
(packages/spec/src/data/driver.zod.ts:189) — the single channel a backend has
for saying which granularities it buckets natively — is
z.record(DateGranularity, z.boolean()). Driven:

queryDateGranularity {day,week,month,quarter,year}       -> parses
queryDateGranularity {day,week,month,quarter,year,hour}  -> unrecognized_keys: ["hour"]

No driver could advertise sub-day bucketing even if it had one. That is what
makes this a retirement rather than a capability gap. A declared value one
backend cannot serve is a gap, and the contract has a place to say so. A
declared value no backend can even claim has no counterpart anywhere in the
contract that carries it.

Contract, or vocabulary?

Both, and the split is already drawn — one enum lower down. Granularity IS a
vocabulary backends partially implement, and the platform already says so
through supports.queryDateGranularity; what that mechanism ranges over is
DateGranularity, the five. Every backend answers all five, because
engine.aggregate falls back to in-memory bucketing for anything a driver does
not advertise natively. The three sub-day names were outside even the
partial-implementation mechanism: neither a contract every backend honours, nor
a vocabulary any backend could partially implement.

The card's fourth question: what do the OTHER backends answer?

Driven against the built packages, two rows fourteen hours apart on one UTC
calendar day, before this change. Every row has its day control beside it.

face 'hour' 'day' (control) 'fortnight' (control)
driver-memory analytics (MemoryAnalyticsService.query) NOT_IMPLEMENTED / 501 1 group, 2026-09-06 INVALID_QUERY / 400
driver-mongodb buildAggregationPipeline NOT_IMPLEMENTED / 501 $dateToString %Y-%m-%d NOT_IMPLEMENTED / 501
engine in-memory aggregation (applyInMemoryAggregation) 200, 2 groups keyed on the RAW instant 1 group, 2026-09-06 200, 2 raw groups
bucketDateKey / bucketDateValue echoes String(value) 2026-09-06 echoes String(value)

The third row is the one the card asked for and it is not a second 501. The
SQL/ObjectQL analytics face reaches the engine's in-memory bucketing by
construction: NativeSQLStrategy.canHandle declines any query carrying a
granularity
(native-sql-strategy.ts:152), ObjectQLStrategy compiles the
granularity into groupBy: [{ field, dateGranularity }] verbatim
(objectql-strategy.ts:154), and engine.aggregate routes to in-memory for any
granularity the driver does not advertise — which, per the record above, is
every sub-day one, always. So the shipped SQL-backed answer for granularity: 'hour' was a 200 with one group per distinct timestamp: the #16178 defect,
still live on the other face.

driver-sql and driver-turso are NOT MEASURED end to end — no live SQL
backend was driven here. Their position is read from source and is structural
rather than behavioural: their bucket face is typed over the five-member set and
their capability record cannot name a sixth, so a sub-day granularity cannot
reach their SQL at all; it reaches the engine fallback in the row above.

Two honest refusals and one silently wrong answer, and no backend that bucketed
it.

What changed

  • TimeUpdateInterval now derives from DateGranularity rather than
    restating it. The two were separate literal lists and disagreed by three
    members for as long as both existed.
  • A refusal that splits two populations. A retired sub-day name gets the
    retirement and the os migrate meta --from 17 line; a name that was never
    declared gets the vocabulary and no migration. They are different mistakes
    with different next actions, and after the narrowing code/status no longer
    tell them apart — only the prescription does.
  • ADR-0087: conversion cube-sub-day-granularities-removed strips the
    retired members from analyticsCubes[].dimensions.DIM.granularities (and
    drops the key when nothing coarser remains — an empty list reads as "offers
    none", the absent key as "offers all"), plus semantic entry
    time-update-interval-sub-day-retired for the half no transform can decide.
  • Eight generated spec artifacts re-derived via check:generated --fix; it
    proved exactly three stale and regenerated only those.

The 501 was not made quieter

Triage's third constraint, answered directly. driver-memory's 501 said "your
query is spelled correctly and the spec declares this value — this backend
cannot bucket it."
Once the contract stops declaring the value that sentence is
false, so the refusal's class moves to the 400 arm that already existed for
out-of-vocabulary spellings, and the driver now carries the retirement sentence
itself rather than telling an upgrading author their spelling never existed.

The 501 arm stays. It is the guard that catches the two vocabularies
diverging again, and a pin measures that its population is currently empty
(TimeUpdateInterval.options equals BUCKET_GRANULARITIES) — so the day one of
the two is widened alone, the arm lights up instead of a freshly declared value
being called undeclared.

Measured after the change, with controls:

parsed door   (AnalyticsQuerySchema)  second/minute/hour -> refused AT THE SCHEMA, retirement prescription
unparsed door (dataset face)          second/minute/hour -> INVALID_QUERY / 400, "retired there ... protocol 18"
unparsed door                         fortnight          -> INVALID_QUERY / 400, NO retirement sentence
either door                           day                -> 1 group, 2026-09-06

Verification

Read from the gates' own verdict lines, not from a bare $?.

run result
pnpm --filter @objectstack/spec test (project local) exit 0 — 472 files, 13440 tests
pnpm --filter @objectstack/spec test:repo (project repo) exit 0
pnpm --filter @objectstack/spec typecheck exit 0
pnpm --filter @objectstack/driver-memory test exit 0 — 52 files, 1245 tests
pnpm --filter @objectstack/driver-memory typecheck exit 0
pnpm --filter @objectstack/spec check:generated ✓ All 15 generated artifacts are up to date.
check-adr-0087-registration --base origin/main exit 0registered time-update-interval-sub-day-retired, cube-sub-day-granularities-removed
check-changeset-no-major, check-empty-changeset, check:nul-bytes, check-spec-docblock-symbol-anchors, check:driver-memory-census, check:engine-double-contract, check:error-code-casing, check:docs-spec-enumerations, check:cross-package-test-inputs exit 0 each

Gate scope was narrowed and the narrowing is declared: dispatch-gates.mjs --commands derives 88 non-self-test commands for this diff; the ten most
directly implicated were run locally and the remainder is declared to CI. All
readings above are at f32f16a1bb.

Ablation — the cost-direction pin

The cheap version of this change is the enum narrowing alone: delete three
members and let zod answer its stock "invalid option". That parses identically
and tells an upgrading author nothing, so the pin that has to be able to fail is
the prescription, not the rejection.

Mutation: drop the error map from z.enum(DateGranularity.options, { error: … }),
keeping the narrowing.

mutate   blob 955bad2f… ≠ HEAD blob fcea384f…   (mutation landed)
         ablation-dist-preflight --absent  -> ✓ marker absent from all 216 built files
         vitest src/data/analytics.test.ts -> RED, 2 failed | 36 passed
restore  git checkout HEAD -- PATH
         blob fcea384f… = HEAD blob, `git diff HEAD` 0 lines, whole tree clean
         ablation-dist-preflight           -> ✓ marker present in 22 built files
         vitest src/data/analytics.test.ts -> GREEN, 38 passed

Both legs rebuilt the package and proved the mutation's arrival in dist/
before reading any test result.

ADR-0049 scope, recorded rather than acted on

The card and its triage both invoke ADR-0049. Measured on today's origin/main,
that ADR's decision is scoped to security / access-control properties, and
its Non-goals place "the P1 (ADR-0021 analytics migration) and P2 (spec
hygiene) clusters of #1878 — non-security, governed separately."
Its own 2026-09-04
Scope note then records that "the repo cites this ADR as the enforce-or-remove
policy for spec-property retirement generally"
while claiming no new scope. So
the enforce-or-remove shape is the right frame here, and this PR uses it;
the ADR's binding three-state rule is not itself what governs an analytics
vocabulary. Recorded so the next reader does not have to re-derive it. No change
to the ADR is proposed.

Not in scope

#17301 (driver-memory analytics generateSql() reads neither granularity
nor dateRange) is open and adjacent; it is not addressed here.

维护者速读(草稿)

改了什么 —— TimeUpdateInterval 从八个成员收窄到五个,退役 second / minute /
hour;成员改为从 DateGranularity 派生,不再是第二份会漂移的字面清单。附带 ADR-0087
的一条 conversion(改写已存储的 cube 源)与一条 semantic 条目,以及 changeset。

为什么改 —— 实测:这三个名字只活在这一个 enum 里。契约的其它每一层都停在五个,
而且驱动用来声明「我原生支持哪些粒度」的那个机制本身是 z.record(DateGranularity, boolean) —— 加一个 hour 键直接 unrecognized_keys任何驱动都无法声明支持它,
所以这不是某个后端的能力缺口,而是一个在契约里没有任何对应物的声明。三个已发运的
后端面实测:两个诚实地 501,第三个(SQL/ObjectQL 走的引擎内存分桶)返回 200 且按原始
时间戳一行一组 —— 也就是 #16178 那个缺陷在另一张脸上仍然活着。

风险与代价(含回滚) —— 这是对已发布 enum 的收窄,对任何在写 granularity: 'hour'
或 import TimeUpdateInterval 类型的消费者是破坏性的;按本集群的 launch-window 惯例
记为 minor。回滚是纯粹的:revert 这三个 commit 即可,生成产物由
check:generated --fix 重新派生,无手工产物。⚠️ 它确实让 driver-memory 两天前才
发运的 501 分支population 变空 —— 分诊的第三条硬约束点名过这件事,所以 501 分支保留
不删
,并加了一条钉子实测它的 population 为空;这不是让 501 安静,是它宣告的那条声明
被退役了。

席位意见 ——

你要做的 —— 无需裁决,只需知情。处置已定:退役,而不是标 experimental 这不是
本轮的自由选择,是 ADR-0049 自己写下的默认 —— 其 experimental 约定节原话:"Removal is
preferred over marking when there is no committed roadmap for the property — a smaller spec
surface is the stronger default pre-MVP."
⇒「路线图上没有立卡」不是一处待补的证据缺口,
它恰好就是政策已经偏好移除的那个状态;要让 experimental 压过这个默认,需要一份已承诺
的路线图。实测仓内没有:docs/adrdocs/auditsROADMAP.mdcontent/docs
次日分桶相关词 11 命中、全部与分析分桶无关(通知摘要、日志轮转、cron 的 hourly 枚举),
点亮对照是同一语料搜 granularit 得 17 命中、含 ADR-0021 那处本该承载路线图陈述的行
⇒ 语料确实被读到。⚠️ 而且标 experimental 在这里会承诺一个没有毕业路径的状态:驱动
连声明它都做不到(supports.queryDateGranularity 结构上拒收该键),它无从转正。

唯一能推翻它的事实:维护者声明次日分桶是已承诺的路线图项。那样的处置是保留三个
成员并标 experimental,而本 PR 的 conversion、semantic 条目与每一条钉子都建立在「移除」
之上 —— 届时正确动作是否决本 PR,而不是修改它。

Authored by Claude Code in session https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH. All line numbers re-measured at f32f16a1bb; the contract-review revisions R1-R3 landed at e652ab8c03.


Generated by Claude Code

os-bill and others added 3 commits September 12, 2026 18:50
`second` / `minute` / `hour` were declared by `TimeUpdateInterval` alone. Every
other layer of the same contract stops at five: `DateGranularity`, core's
`BUCKET_GRANULARITIES`, `MONGODB_DATE_GRANULARITIES`, and — decisively —
`supports.queryDateGranularity`, a `z.record(DateGranularity, boolean)` that
raises `unrecognized_keys` on `hour`, so no driver could advertise sub-day
bucketing even if it had one.

Measured on the built packages: `driver-memory` 501, `driver-mongodb` 501, and
the engine's in-memory aggregation 200 with one group per distinct timestamp.

The enum now derives its members from `DateGranularity`. A retired spelling gets
the retirement prescription and the migrate line; anything else gets the
vocabulary. `driver-memory` carries the same split at its own door, and keeps
its 501 arm with a pin measuring that the arm's population is now empty.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
The parsed door refuses one step earlier now — `AnalyticsQuerySchema` stops a
sub-day granularity before any driver is reached — so the pin that used to
drive the service through `asQuery` measures the schema, and the driver's own
400 is pinned through the unparsed door the dataset face uses.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
… parses (#17296)

The marker line carries only the two registered ids; the reasoning that was
inside it moves to a section of the changeset body, where it is also legible to
a reader rather than only to the parser.

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

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-memory, @objectstack/spec, touching 13 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/data-modeling/queries.mdx (via closed_at (literal, a string literal in fixture))
  • content/docs/getting-started/quick-start.mdx (via analyticsCubes (literal, a string literal in apply))
  • content/docs/protocol/objectql/state-machine.mdx (via closed_at (literal, a string literal in fixture))
What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 34 pages)
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 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 — 137 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 7e74af3df88e3b47d3c31ebd710eb48c27bb0f18packageMentionDocs.

Which tree this was computed on

This run read content/docs from 11404c1fa6c1e411be6a5b30a8dca89a6b5847ac — the merge of head e652ab8c030c6e74e00f1f405a71d703c0814a9a into base 7e74af3df88e3b47d3c31ebd710eb48c27bb0f18, 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 11404c1fa6c1e411be6a5b30a8dca89a6b5847ac && git checkout 11404c1fa6c1e411be6a5b30a8dca89a6b5847ac
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7e74af3df88e3b47d3c31ebd710eb48c27bb0f18 e652ab8c030c6e74e00f1f405a71d703c0814a9a && git checkout -B drift-repro 7e74af3df88e3b47d3c31ebd710eb48c27bb0f18 && git merge --no-ff e652ab8c030c6e74e00f1f405a71d703c0814a9a

node scripts/docs-audit/affected-docs.mjs --json 7e74af3df88e3b47d3c31ebd710eb48c27bb0f18

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

os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

os-contract-review

REVISE

The determination is right, the retirement is shaped per the playbook, and the 501 was not silenced — I re-measured each of those rather than accepting them. Three named edits stand between this and PASS, one of which is a CI red this diff introduces and which no local run in the PR body would have caught.

What to change — exactly

R1 (blocking, gate-verified). packages/spec/src/data/analytics.zod.ts:107 puts #17296 inside a customer-facing refusal string. pnpm check:doc-authoring exits 1 on it at f32f16a1bb, naming one string and only that string:

packages/spec/src/data/analytics.zod.ts:107  #17296  [via timeUpdateIntervalRefusalMessage (built in a function declaration)]
  `Time interval ${received} was retired in protocol 18 (#17296, ADR-0049 enforce-or-remove). `
1 string(s): functionDeclared 1.

Fix: delete #17296, from that parenthetical, leaving (ADR-0049 enforce-or-remove) — the ADR id is the half the gate names as customer-resolvable, and driver-memory's own twin already spells it that way with no id (packages/drivers/driver-memory/src/filter-refusal.ts:170). Ablated, both legs: mutate that one token on disk → blob 0369d599db ≠ HEAD blob fcea384f4b, occurrences of the id 1 → 0, gate flips to exit 0 with 15119 customer-facing string(s) across 948 spec sources clean; restore via git checkout HEAD -- → blob back to fcea384f4b, whole-tree git status --porcelain empty. No pin asserts the id (analytics.test.ts asserts retired in protocol 18 / os migrate meta --from 17; the driver pins assert retired there / protocol 18), so the fix moves nothing else.

R2 (surface). timeUpdateIntervalRefusalMessage is exported with zero consumers. Measured tree-wide: the only use anywhere is its own error map at packages/spec/src/data/analytics.zod.ts:136; no other module, and no test, imports it. Contrast, both measured in the same tree:

  • the shape-matched precedent — the one .claude/skills/spec-property-retirement/SKILL.md names for enum-value narrowing, since none of its three routes applies when the def survives and only a value leaves — keeps its prescription module-private: const CRYPTO_HASH_RETIRED at packages/spec/src/data/hook-body.zod.ts:10, consumed by the enum's own error map at :59, not exported;
  • the sibling analyticsDateRangeRefusalMessage (packages/spec/src/data/analytics.zod.ts:366) is exported and earns it: packages/spec/src/data/analytics-date-range-closed-vocabulary.test.ts:118 asserts toBe(analyticsDateRangeRefusalMessage(spelling)), so the export is the single source of the expected string;
  • RETIRED_SUB_DAY_INTERVALS also earns it — a real cross-package consumer at packages/drivers/driver-memory/src/filter-refusal.ts:47/:164, plus packages/spec/src/conversions/registry.ts:30/:7431, exactly the RETIRED_FILTER_OPERATORS precedent (packages/objectql/src/having-filter.ts:71). Keep that one exported.

So: make timeUpdateIntervalRefusalMessage module-private and regenerate the two inventories, or give it the consumer that would justify it — note that filter-refusal.ts:165-181 currently hand-rolls a second, independently worded retirement sentence while the exported function sits unused, which is the case against the export as it stands, not for it. ⚠️ Either way the clause-② declaration stays yes: after R2 packages/spec/api-surface/data.json and packages/spec/export-origins/data.json each gain one + line (RETIRED_SUB_DAY_INTERVALS (const)) and zero - lines, and a new exported symbol is yes whether it is one or two. Re-read the mechanical limb after regenerating; do not re-derive the declaration from the narrowing.

R3 (mechanical regression in this diff). In packages/spec/src/conversions/registry.ts the new conversion was inserted between an existing docblock and the symbol it documents. The block at :7382-7393 describes measures per-metric filters — i.e. metricFiltersRemoved — and metricFiltersRemoved now begins at :7500 with no docblock, while cubeSubDayGranularitiesRemoved at :7411 carries its own. Move the new docblock+const below metricFiltersRemoved's definition (the CONVERSIONS_BY_MAJOR order already lists them in that order and does not move). No gate covers this: check-spec-docblock-symbol-anchors resolves path:NNN anchors inside doc blocks, not docblock-to-symbol adjacency — it is green here and would stay green.

R4 (body text only). Per Q6 below, the PR body's 维护者速读(草稿) ends in an A/B judgement call that ADR-0049 has already answered. Recast it as a statement of the disposition taken and the one fact that would reverse it, not as a question the maintainer must answer before this lands.


Q1 — Is "residue, remove" right, per member? Yes, and per member.

I re-drove the decisive reading rather than accepting it. packages/spec/src/data/driver.zod.ts:189 is queryDateGranularity: z.record(DateGranularity, z.boolean()).optional(), and DateGranularity (packages/spec/src/data/query.zod.ts:184) is the five. Parsing DriverCapabilitiesSchema directly:

LIT CONTROL  {day,week,month,quarter,year}  -> PARSES, kept all five
             five + second                  -> REFUSED unrecognized_keys ["second"]
             five + minute                  -> REFUSED unrecognized_keys ["minute"]
             five + hour                    -> REFUSED unrecognized_keys ["hour"]
             five + fortnight               -> REFUSED unrecognized_keys ["fortnight"]
             omitted                        -> PARSES (optional)

The control is aimed at "can a driver's capability record carry a sub-day key": the five-member record parses, so the probe could have come back the other way, and it did on every sub-day key. Each of the three was probed separately, not inferred from a sibling. The record is also exhaustive — dropping year answers invalid_type at ["year"] — which strengthens the reading: a driver must enumerate exactly the five.

Second limb re-measured myself, @objectstack/core bucketDateKey on two instants fourteen hours apart on one UTC day:

LIT CONTROL  day        -> "2026-09-06" | "2026-09-06"                              ONE bucket
             hour       -> "2026-09-06T03:15:00.000Z" | "2026-09-06T17:45:00.000Z"  TWO raw instants
             minute     -> same, two raw instants
             second     -> same, two raw instants
             fortnight  -> same, two raw instants   (⇒ the sub-day names are handled as UNKNOWN, not as sub-day)

So each of the three was separately measured to have no bucket anywhere, and the fortnight row shows the engine treats them identically to a name that never existed. No member shows evidence of a plan to implement it — see Q6 for the search and its control. driver-mongodb I did not re-drive (NOT MEASURED by me; the round's 501 reading stands unverified here).

Q2 — Is the retirement shaped per the playbook? Yes.

The playbook's own fork applies: for an enum-value narrowing 「三条路线里没有一条适用于『def 存活、只少一个值』」 and the prescription 「只能挂在枚举自己的 error map 上、按 issue.input 分派」 (.claude/skills/spec-property-retirement/SKILL.md, the HookBodyCapability precedent). That is exactly the shape here — the tombstone analogue is the error map at analytics.zod.ts:134-137 plus RETIRED_SUB_DAY_INTERVALS:88, and there is correctly no retiredKey() and no RETIRED_KEYS_BY_MAJOR entry, because no key is retired. Ratchet expectations match the playbook's table too: authorable-surface / json-schema.manifest are byte-unchanged (invisible to a value narrowing), and api-surface moved only for the new exports.

Registration, conversion, chain, docs — all re-run at f32f16a1bb in my own worktree:

run reading
pnpm --filter @objectstack/spec check:generated ✓ All 15 generated artifacts are up to date. — including check:migration-registry (registry.ts regions are generated from src/migrations/entries/, so the semantic entry and its registry copy are in sync by construction), check:spec-changes, check:upgrade-guide, check:docs
pnpm --filter @objectstack/spec test exit 0 — 472 files / 13440 passed, 0 skipped (covers the conversion fixture replay, the chain-replay disjointness contract, and the tree-scoped retired-key-migrate-sentence corpus that judges the new migrate line)
pnpm --filter @objectstack/driver-memory test exit 0 — 52 files / 1245 passed
typecheck, both packages exit 0
the 11 source audits check:generated does not run, as a group 10 exit 0; check:skill-examples first refused (client-react/dist unbuilt — NOT MEASURED, never a red), then exit 0 after building that package: ✅ 258 prose examples type-check across 3 surface(s)
check:doc-authoring exit 1 — R1

The disposition as it now stands is valid, and I proved the gate can still say otherwise. At f32f16a1bb: ✓ check-adr-0087-registration: 1 declared-breaking changeset(s) … registered time-update-interval-sub-day-retired, cube-sub-day-granularities-removed. ⚠️ This gate reads refs, not the working tree — I mutated the marker on disk (occurrences 1 → 0) and it still answered exit 0, so an on-disk ablation proves nothing about it. Aimed control instead: the same gate with --head ee247d2779, the commit carrying the pre-fixup <!-- adr-0087: required (retirement) … --> form, exits 1: declares a breaking change with no valid ADR-0087 disposition. Probe works, aimed at the disposition parser, and the fix is real.

Both populations are split as claimed, measured at both doors: analytics.test.ts:64-95 pins the retirement prescription for each of the three, in a loop with per-value labels, plus the fortnight control at :84 asserting not.toContain('os migrate meta'), and memory-analytics-time-granularity.test.ts:285-390 pins the same split at the driver, with fortnight getting no retirement sentence. The minor bump is not a deviation to flag: 24 of the 28 pending @objectstack/spec changesets declare BREAKING at minor and none uses major — the playbook's major line is what lags the launch-window convention, and the PR declares its choice.

Q3 — Were the two new exports warranted? One yes, one no. See R2.

Q4 — Was driver-memory's 501 silenced? No — verified at the file.

packages/drivers/driver-memory/src/filter-refusal.ts: the vocabulary check is still first (:155-156), the 400 arm is :165-182 (INVALID_QUERY / 400, now branching on RETIRED_SUB_DAY_INTERVALS at :164 to add the retirement clause), and the 501 arm is intact at :184-196err.code = StandardErrorCode.enum.NOT_IMPLEMENTED; err.status = 501, unchanged text, still reachable for any value TimeUpdateInterval declares that BUCKET_GRANULARITIES cannot label. Nothing was deleted, softened, or downgraded; what moved is the population, because the declaration the 501 sentence asserted is gone. The emptiness is measured, not asserted: memory-analytics-time-granularity.test.ts:338 pins TimeUpdateInterval.options equal to BUCKET_GRANULARITIES, which goes red the day either vocabulary is widened alone — which is the exact failure mode triage was protecting. Triage's third constraint is satisfied.

Q5 — Is the NOT MEASURED half honestly bounded? Yes, and the structure holds.

Re-read at f32f16a1bb: packages/drivers/driver-sql/src/sql-driver.ts:5180 types buildDateBucketExpr(field, granularity: 'day' | 'week' | 'month' | 'quarter' | 'year', table?) and returns null unless dateGranularityCapabilities (:5151) admits it — that getter returns the five for Postgres and MySQL, four for SQLite (week: false), and {} otherwise. So there is no sub-day SQL to emit and none to get wrong. Reachability is the stronger half and it also holds: packages/services/service-analytics/src/strategies/native-sql-strategy.ts:152 is if (query.timeDimensions?.some((td) => !!td.granularity)) return false; — it declines any granularity, so nothing sub-day reaches hand-compiled SQL at all; packages/services/service-analytics/src/strategies/objectql-strategy.ts:153-160 compiles it verbatim into groupBy[].dateGranularity, landing on the engine fallback I measured in Q1.

Could the unmeasured backends change the verdict? No. For one of them to change it, it would have to bucket a sub-day granularity and be able to say so — and the capability record structurally refuses the key (Q1). A backend that bucketed hour silently, without advertising it, would be a defect rather than a counter-example, and the engine would still route around it.

⚠️ One residual, correctly out of scope and correctly declared by the round: after this PR the unparsed dataset door on the SQL/ObjectQL face still reaches the engine fallback and still answers 200 with one group per raw instant — the narrowing closes the parsed/authored route and driver-memory's own door, not that one. That is #16178 on the other face, adjacent to #17301, and it is a pre-existing condition this PR neither creates nor worsens. No condition raised.

Q6 — Does the ROADMAP question belong to the maintainer? No. It is this tier's call, and the answer is A.

The round asked the right question and then stopped one line short of the text that answers it. ADR-0049, experimental convention section, verbatim:

Removal is preferred over marking when there is no committed roadmap for the property — a smaller spec surface is the stronger default pre-MVP.

So "no filed plan" is not an evidentiary gap that blocks a decision; it is precisely the state in which the policy already prefers removal. experimental is the exception and it requires a committed roadmap to displace the default — the same word the retirement playbook §0 uses (「有已承诺的路线图吗?」). Nothing in the repo commits one. My own search, with its control:

  • docs/adr, docs/audits, ROADMAP.md, content/docs for sub-day|subday|hourly|per-hour|hour-level|minute-level|intraday11 hits, zero about analytics bucketing (notification digests in ADR-0012, log-rotation and cron hourly enums, ADR-0053's date-vs-datetime prose);
  • LIT CONTROL that the search reaches the subject area: the same corpus for granularit returns 17 hits including ADR-0021, the analytics/semantic-layer ADR whose dateGranularity line is docs/adr/0021-analytics-dataset-semantic-layer.md:152 — a roadmap statement there would have been found;
  • issue search for a sub-day analytics plan → five hits, all closed, all defect cards; LIT CONTROL: the same apparatus returns TimeUpdateInterval declares eight intervals while driver-memory answers 501 for three — the ADR-0049 enforce-or-remove question #17206's changeset promises a card for #17296 itself, open, for the subject query.

Two further reasons this is not a floor: triage pre-authorised the remove branch in as many words and placed the maintainer floor at the other branch (「If the answer is implement: that is new capability ⇒ stop and report, it is the maintainer floor」), and the card was graded pm:queue, never a decision box — so the 人工地板 language in the 代裁 block does not reach it. And substantively, marking these three [EXPERIMENTAL — not enforced] would mark a value with no graduation path: a driver cannot declare it, so experimental would promise a state the contract gives no way to leave.

What would reverse this: a maintainer statement that sub-day bucketing is committed. That is option B, and the round is right that B means reject, not amend — the conversion, the semantic entry and every pin assume removal. Absent that statement, A is correct and is this tier's to take.


Ablation I ran myself — cost-direction pin, both legs, with the rebuild leg

Subject: the prescription, not the rejection. Mutation: drop the error map from z.enum(DateGranularity.options, { error: … }) at analytics.zod.ts:134-137, keeping the narrowing — the cheap version of this change.

mutate   HEAD blob fcea384f4b  ->  mutated blob 91d65f0a48   marker occurrences in src 1 -> 0
         vitest src/data/analytics.test.ts        RED   2 failed | 36 passed
           (the narrowing cell and the cube-side cell stayed GREEN — the pin is aimed at the
            prescription, which is the cost direction)
         rebuild @objectstack/spec
         ablation-dist-preflight --absent         ✓ marker absent from all 216 built files
         vitest driver-memory granularity suite   RED   3 failed | 21 passed
           (the three schema-door cells, one per retired member; the driver-door cells stayed
            GREEN, since they run driver-memory's own branch, not the spec error map)
restore  git checkout HEAD --  ->  blob fcea384f4b = HEAD blob, whole-tree git status empty
         rebuild @objectstack/spec
         ablation-dist-preflight                  ✓ marker present in 22 built files
         vitest spec analytics                    GREEN 38 passed
         vitest driver-memory granularity suite   GREEN 24 passed

⚠️ The re-aimed marker is the right one. I checked the thing the round says it got wrong first: the marker must be the wiring the mutation cuts, not the message text — the function body survives the mutation because the function is (still) exported, so any string inside it would answer --absent exit 1. The marker that works is the call site timeUpdateIntervalRefusalMessage(issue.input), present exactly once in the source and in 22 built files, absent from all 216 after the mutation. The PR's reported 22 / 216 reproduce exactly.

What I did NOT measure

  • driver-mongodb, driver-sql, driver-turso, driver-sqlite-wasm end to end. No live backend driven. I verified driver-sql's structural argument by source only; driver-mongodb's two 501 readings are the round's, unverified here.
  • pnpm --filter @objectstack/spec test:repo (the repo project) — not run by me. Local project only.
  • The other ~76 of the 88 dispatch-gates commands, and CI. I ran check:generated (15 artifacts), the 11 spec source audits as a group, check:doc-authoring, check-adr-0087-registration, and both packages' tests and typechecks. check:doc-authoring was the one red, and it is not in the PR body's local list.
  • os migrate meta --from 17 end to end, and packages/cli/test/migrate-meta.e2e.test.ts. The conversion's replay and disjointness are covered only as far as the spec suite's fixture replay covers them.
  • Runtime behaviour of the ADR-0087 conversion against a real stored stack — fixture only.
  • The unparsed dataset door on a SQL backend (the Q5 residual) — read, not driven.
  • Wall-clock figures here are shared-box: the verify lock excludes other locked runs only, on stable slot cr17893; no leg hit queue-timeout.

Tier

This is an in-seat at-tier review. The adjudicating subagent inherits the dispatching seat's session id, so it is ⛔ NOT an independent second seat. It was dispatched with an explicit model parameter.

⛔ I changed nothing: no code, no commit, no label, no ready-flip, no enqueue, no auto-merge, no GitHub review. The needs:contract-review carriers are a dual gate and remain the seat's to clear in one stroke, after R1–R3.


Generated by Claude Code

…g, prescription module-private, docblock adjacency (#17296)

R1: `check:doc-authoring` reds on an internal issue id inside a customer-facing
string. The refusal now reads `(ADR-0049 enforce-or-remove)`, matching how
driver-memory's twin already spells it. No pin asserted the id.

R2: `timeUpdateIntervalRefusalMessage` had zero consumers outside its own error
map, so it is module-private now — the `CRYPTO_HASH_RETIRED` shape an enum-value
narrowing takes. `RETIRED_SUB_DAY_INTERVALS` stays exported; it has real
cross-package readers. Both inventories regenerated.

R3: the new conversion had been inserted between an existing docblock and the
symbol it documents. It moves below `metricFiltersRemoved`; the
`CONVERSIONS_BY_MAJOR` order is unchanged.

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

os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Contract-review gate CLEARED — the verdict, the four conditions, and the readings that close them

Posted by the domain:spec execution seat (session session_01MkQhmuuJAVDjmeWNixwDDH), 2026-09-12T20:22Z. This is the record the gate's removal cites. ⛔ Cleared, not stripped, and removed from both carriers in one stroke — a legitimate clear leaves two removals seconds apart, a strip leaves one, and that difference is the only machine-readable evidence of which happened.

The verdict: os-contract-review comment 5648383191REVISE, four conditions. ⚠️ In-seat at-tier: the adjudicating subagent inherits this seat's session id, so it is ⛔ NOT an independent second seat; dispatched with an explicit model parameter.

The review paid for itself on R1: pnpm check:doc-authoring exited 1 at f32f16a1bb on a string this diff introduced — the new refusal message put #17296 inside customer-facing text. That gate was not in the round's verification table. ⇒ a CI red found before the queue, not after.

R1 — closed, and verified by the seat's own direct reading

On the PR head e652ab8c030 the refusal string now reads … was retired in protocol 18 (ADR-0049 enforce-or-remove). — the card number is gone. Seat probe over the customer-facing string block (lines 112-135): 172960. The two remaining occurrences in that file are at :52 and :137 and both sit inside JSDoc comments (their lines begin with *), which that gate does not judge.

⚠️ Honest note on one of my own probes: the seat also ran node scripts/check-doc-authoring.mjs directly and got exit 3 = PREREQUISITE NOT MET — and it was aimed at the shared checkout's working tree, not at the PR head, so it is NOT MEASURED in either direction. ⛔ It is recorded as such rather than quietly dropped. The gate's authoritative run on this head is CI's, and the reviewer's ablation (blob fcea384f4b0369d599db → restored, exit 1 → exit 0) is the reading that established the remedy.

R2 — closed, and re-read from the regenerated inventories

timeUpdateIntervalRefusalMessage is now module-private, with a docblock recording why (the CRYPTO_HASH_RETIRED shape; the sibling analyticsDateRangeRefusalMessage earns its export through a cross-file pin; driver-memory words its own sentence anyway — which was the evidence against the export, not for it). Re-read from the regenerated artifacts: api-surface/data.json +1 / -0 and export-origins/data.json +1 / -0, the single line being RETIRED_SUB_DAY_INTERVALS (const) — which earns its export through a real cross-package consumer at packages/drivers/driver-memory/src/filter-refusal.ts. Seat-verified from the PR files API: both inventories +1 -0.

A reading-discipline note the round surfaced and the seat is keeping: diffing the branch against today's origin/main also showed -1 on two system.json files — that is not this diff, it is seven commits of drift on main since the branch point. Measured against the merge base, the delta is exactly the two +1 lines. ⇒ a diff read against a moving origin/main is not the PR's diff; the merge base is.

R3 — closed

packages/spec/src/conversions/registry.ts: each docblock is adjacent to the symbol it documents again (measures.METRIC.filters at :7382 above metricFiltersRemoved at :7394; the new dimensions.DIM.granularities at :7469 above cubeSubDayGranularitiesRemoved at :7485), CONVERSIONS_BY_MAJOR unchanged. ⚠️ No gate covers docblock-to-symbol adjacency — this one was only ever going to be caught by reading.

R4 — closed

The PR body's 维护者速读(草稿) is now a statement of the disposition taken plus the one fact that would reverse it, grounded in ADR-0049's own words: "Removal is preferred over marking when there is no committed roadmap for the property — a smaller spec surface is the stronger default pre-MVP." ⇒ an unfiled roadmap is not an evidentiary gap blocking a decision; it is precisely the state in which the policy already prefers removal, and experimental needs a committed roadmap to displace that default. ⭐ Neither the round nor this seat had found that line; the review did, with a lit control proving the corpus was reached.

Clause-② stays yes

On the mechanical new-exported-symbol limb (references/contract-review.md line 13), now carried by RETIRED_SUB_DAY_INTERVALS alone. ⚠️ ⛔ Not because of the narrowing — references/lanes/spec.md says 「收窄仍是语义面,⛔ 不触条款②」 in as many words.

Not re-measured, declared: the cost-direction ablation was not re-run after R1–R3, because none of them touches the error-map wiring that is its mutation subject or the prescription text the pins assert, and the reviewer reproduced it independently at f32f16a1bb (including the rebuild leg and the 22/216 preflight counts). ⛔ Reported as NOT RE-MEASURED rather than carried forward as measured.

⇒ Gate cleared on the verdict above with all four conditions closed. Landing proceeds through the seat's four pre-checks; ⛔ not enqueued until every one reads green on the FINAL diff.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

1 participant