fix(spec)!: TimeUpdateInterval retires its three sub-day intervals and derives its members from DateGranularity (#17296) - #17893
Conversation
`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
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
|
os-contract-review REVISEThe 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 — exactlyR1 (blocking, gate-verified). Fix: delete R2 (surface).
So: make R3 (mechanical regression in this diff). In R4 (body text only). Per Q6 below, the PR body's Q1 — Is "residue, remove" right, per member? Yes, and per member.I re-drove the decisive reading rather than accepting it. 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 Second limb re-measured myself, So each of the three was separately measured to have no bucket anywhere, and the Q2 — Is the retirement shaped per the playbook? Yes.The playbook's own fork applies: for an enum-value narrowing 「三条路线里没有一条适用于『def 存活、只少一个值』」 and the prescription 「只能挂在枚举自己的 Registration, conversion, chain, docs — all re-run at
The disposition as it now stands is valid, and I proved the gate can still say otherwise. At Both populations are split as claimed, measured at both doors: Q3 — Were the two new exports warranted? One yes, one no. See R2.Q4 — Was
|
…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
Contract-review gate CLEARED — the verdict, the four conditions, and the readings that close themPosted by the The verdict: ⭐ The review paid for itself on R1: R1 — closed, and verified by the seat's own direct readingOn the PR head
R2 — closed, and re-read from the regenerated inventories
⭐ A reading-discipline note the round surfaced and the seat is keeping: diffing the branch against today's R3 — closed
R4 — closedThe 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 Clause-② stays
|
Fixes #17296
Clause-②: yes
The determination, per member
second,minuteandhourare residue, and the same reading answers allthree — 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.
TimeUpdateInterval(packages/spec/src/data/analytics.zod.ts:53before,:134after)TimeUpdateInterval.optionsDateGranularity(packages/spec/src/data/query.zod.ts:184) — whatQueryAST.groupBy[].dateGranularityand every driver bucket expression are typed byDateGranularity.options@objectstack/core'sBUCKET_GRANULARITIES(packages/core/src/utils/datetime.ts:215) — the canonical bucket-KEY output contract a drill-down crossesdriver-mongodb'sMONGODB_DATE_GRANULARITIESObject.keys(...)driver-sql'sdateGranularityCapabilities(packages/drivers/driver-sql/src/sql-driver.ts:5151)'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 hasfor saying which granularities it buckets natively — is
z.record(DateGranularity, z.boolean()). Driven: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 isDateGranularity, the five. Every backend answers all five, becauseengine.aggregatefalls back to in-memory bucketing for anything a driver doesnot 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
daycontrol beside it.'hour''day'(control)'fortnight'(control)driver-memoryanalytics (MemoryAnalyticsService.query)NOT_IMPLEMENTED/ 5012026-09-06INVALID_QUERY/ 400driver-mongodbbuildAggregationPipelineNOT_IMPLEMENTED/ 501$dateToString%Y-%m-%dNOT_IMPLEMENTED/ 501applyInMemoryAggregation)2026-09-06bucketDateKey/bucketDateValueString(value)2026-09-06String(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.canHandledeclines any query carrying agranularity (
native-sql-strategy.ts:152),ObjectQLStrategycompiles thegranularity into
groupBy: [{ field, dateGranularity }]verbatim(
objectql-strategy.ts:154), andengine.aggregateroutes to in-memory for anygranularity 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-sqlanddriver-tursoare NOT MEASURED end to end — no live SQLbackend 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
TimeUpdateIntervalnow derives fromDateGranularityrather thanrestating it. The two were separate literal lists and disagreed by three
members for as long as both existed.
retirement and the
os migrate meta --from 17line; a name that was neverdeclared gets the vocabulary and no migration. They are different mistakes
with different next actions, and after the narrowing
code/statusno longertell them apart — only the prescription does.
cube-sub-day-granularities-removedstrips theretired members from
analyticsCubes[].dimensions.DIM.granularities(anddrops 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-retiredfor the half no transform can decide.check:generated --fix; itproved exactly three stale and regenerated only those.
The 501 was not made quieter
Triage's third constraint, answered directly.
driver-memory's 501 said "yourquery 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.optionsequalsBUCKET_GRANULARITIES) — so the day one ofthe two is widened alone, the arm lights up instead of a freshly declared value
being called undeclared.
Measured after the change, with controls:
Verification
Read from the gates' own verdict lines, not from a bare
$?.pnpm --filter @objectstack/spec test(project local)pnpm --filter @objectstack/spec test:repo(project repo)pnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/driver-memory testpnpm --filter @objectstack/driver-memory typecheckpnpm --filter @objectstack/spec check:generated✓ All 15 generated artifacts are up to date.check-adr-0087-registration --base origin/mainregistered time-update-interval-sub-day-retired, cube-sub-day-granularities-removedcheck-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-inputsGate scope was narrowed and the narrowing is declared:
dispatch-gates.mjs --commandsderives 88 non-self-test commands for this diff; the ten mostdirectly 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.
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 analyticsgenerateSql()reads neithergranularitynor
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/adr、docs/audits、ROADMAP.md、content/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 atf32f16a1bb; the contract-review revisions R1-R3 landed ate652ab8c03.Generated by Claude Code