Skip to content

feat(analytics): an authored cube's measures.format and dimensions.granularities take effect on the query doors (#20282, stage 2) - #20635

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20282-cube-format-granularities
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20282-cube-format-granularities

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Part of #20282
Clause-②: yes (narrowing)

Stage 2 of #20282, under claim 5886559774 (session_014EJ1ED8X4MMrT18BhVx4tx, domain:spec seat 2). An AUTHORED analytics cube's measures.format and dimensions.granularities now reach the readers a compiled dataset already reaches. refreshKey is measured only, and nothing is built for it. The card stays open for stage 3 (descriptions) and for the refreshKey decision.

What changes

One Cube shape has three producers: authored cubes (AnalyticsServiceConfig.cubes, which the CLI threads from analyticsCubes), compiled datasets, and ad-hoc inference. Until now, both keys were read only on the compiled-dataset path.

  • measures.format
    • analytics-service.ts#withDeclaredMeasureFormats runs in queryIn, beside the SQL-echo gate.
    • Every measure column a query names now carries its cube measure's declared format as fields[].format. This holds for every strategy (NativeSQL, ObjectQL, the delegated fallback) and for both member spellings.
    • A measure that declares no format gets no key. A value already on the column is never replaced.
    • On the dataset door, the value read is the compiler's copy of the dataset measure's format, the same value enrichResultColumns writes anyway.
    • GET /analytics/meta is unchanged. See the premise checks below.
  • dimensions.granularities
    • analytics-service.ts#withDeclaredGranularityDefaults runs on query() and on the generateSql() dry run, before the source-field gates and strategy selection.
    • It reads dataset-executor.ts#declaredDefaultGranularity, a new function extracted from granularityOf, which now calls it too. Both producers are therefore read by one rule.
    • The rule, exactly the compiled-dataset path's:
      • a single-entry list is the default bucket for a time dimension the query groups by without stating a granularity;
      • a stated granularity always wins;
      • a granularity outside the list is not refused;
      • a list of two or more states no default;
      • a timeDimensions entry that carries only a dateRange, for a dimension the query does not group by, stays a filter.
  • Spec (packages/spec/src/data/analytics.zod.ts, after PR docs(spec): re-anchor the dead tracker citations in stack.zod.ts and data/analytics.zod.ts to the commits that decided them (stage 7) #20616 merged; origin/main merged first through scripts/pm/os-regen-merge.sh as d963f30e33)
    • MetricSchema.format and DimensionSchema.granularities gain describes that state the enforcement.
    • The metric's example values move from the names "currency" and "percent" to numeral patterns, the vocabulary the fields[].format slot documents.
    • content/docs/references/data/analytics.mdx is regenerated with gen:docs.
  • Ledger
    • Both rows in packages/spec/liveness/analytics_cube.json go dead → live. Each cites its readers as file#symbol and the CLI threading producer.
    • The file note's two sentences that named both keys as dead are rewritten.
    • state-counts/analytics_cube.md is regenerated with gen:liveness-counts: live/dead goes from 18/9 to 20/7.
    • The analytics_cube Notes cell in liveness/README.md, which listed both keys among the dead, is rewritten.
  • Changeset: @objectstack/spec minor and @objectstack/service-analytics minor, with a **BREAKING** sentence.

Clause-② (measured arm): yes (narrowing)

  • Widening: two authored keys take effect, and fields[].format is populated for authored cubes. The contract already declares that member.
  • Narrowing, measured:
    • Method: a throwaway service-seam probe, run once on head and once with the fill ablated through scripts/ablation-replace.mjs (round 1 at 958251b6ac; round 3 at e1383be04d, blob 9d77adcd798f → 94b1bc63165a, restored to HEAD). The probe is not committed.
    • Cube: an authored cube whose placed_at declares granularities: ['month'], whose shipped_at declares two intervals, and (round 3) which declares joins: { account: { name: 'crm_account' } }.
request (authored cube; placed_at declares ['month'], shipped_at two intervals; joins: { account } where named) fill ablated (= base behaviour) head
custom-SQL measure grouped by placed_at 200 (raw SQL) 400 INVALID_FIELD, custom-SQL measure (resolveMeasureAggregation)
cross-object measure (sum of account.balance) grouped by placed_at 200 400 INVALID_FIELD, cross-object measure (planCrossObject)
count by placed_at with a where field over account 200 400 INVALID_FIELD, cross-object filter, param: where
count grouped by a one-interval time dimension over account (account.created_at) 200 400 INVALID_FIELD, cannot bucket a cross-object time dimension
count by placed_at beside a multi-hop dimension (account.owner.region) 200 400 INVALID_FIELD, single-hop only
avg by placed_at beside a cross-object dimension (account.industry) 200 400 INVALID_FIELD, non-recombinable measure
count_distinct by placed_at beside a cross-object dimension 200 400 INVALID_FIELD, non-recombinable measure
host whose queryCapabilities offers raw SQL only (a hand override; AnalyticsServicePlugin wires both): plain count grouped by placed_at 200 "No strategy can handle query" (every newly bucketed query)
control: count by placed_at beside a cross-object dimension (recombinable) 200 200
control: avg by placed_at, no cross-object member 200 200
control: custom-SQL measure grouped by shipped_at (two intervals) 200 200
  • Every 400 is byte-identical (code, status, message, member, param, cube) to what the same request with granularity: 'month' stated by hand already got: the fill hands the engine path the very query a hand-stated granularity does. The class is the engine aggregate path's whole refusal set as the new bucketing reaches it, read from ObjectQLStrategy.planCrossObject and resolveMeasureAggregation: a custom-SQL measure; and, on a cube whose members resolve through joins, a measure or where field over a joined object, a timeDimensions entry over a joined object, a multi-hop dimension, and an avg / count_distinct measure beside a dimension over a joined object. planCrossObject's two dataset-definition arms read only compiled-dataset scope, so they cannot fire on an authored cube. Two pins: DECLARED NARROWING (custom-SQL) and DECLARED NARROWING, joined cube (cross-object measure).
  • The changeset carries the **BREAKING** sentence (the class and its remedy) and the disposition registered analytics-cube-single-granularity-default-enforced, a new ADR-0087 D3 semantic entry (round 2, seat note 5889752648 Q4 = B).
  • check-adr-0087-registration: ✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.
  • Round 2 registered it: 18.analytics-cube-single-granularity-default-enforced.ts tells authors that a one-interval list is now a default bucket, including one the protocol-18 conversion cube-sub-day-granularities-removed minted, and (round 3) which queries grouped by it the engine path refuses: the whole class above, plus every newly bucketed query on a raw-SQL-only host. registry.ts is regenerated; spec-changes.json and the upgrade guide were regenerated with no change, because neither projects major-18 entries yet.

Premise checks, against origin/main 7510663c87

  • format on CubeMeta: not done, because the premise does not hold.
    • The dataset path surfaces format only through fields[]. A compiled dataset's getMeta projection is { name, type, title } too.
    • AnalyticsMetadataResponseSchema records the narrowing for this (#6442).
    • content/docs/api/data-api.mdx already sends clients to fields[] for format.
    • The spec contract files (contracts/analytics-service.ts, api/analytics.zod.ts) are outside this claim's surface.
  • granularities refusal: none invented.
    • The dataset path never compares a requested granularity against the list, so there is no refusal to mirror.
    • What a multi-entry list should mean ("Supported Granularities") is an open fork in the report.
  • refreshKey census (tree 958251b6ac; packages/services, packages/drivers, packages/rest, non-test):
    • refreshKey: 0 hits. The repo-wide control finds 9 files.
    • Pre-aggregation, materialized-view and rollup terms: 7 hits, all unrelated (automation subflow rollups, and a driver-sql built-in column flag).
    • ICacheService consumers: plugin-auth rate-limit and secondary storage, runtime inbound rate limit, dispatcher counter store, sms. None is in analytics.
    • service-analytics reads no job, cache or scheduler service. Its one cache is the request-scoped label map in dimension-labels.ts#withLabelFetchCache (the lit control, 1 hit).
    • A scheduler exists (service-job's IJobService: cron, interval and db adapters), and nothing in analytics uses it.
    • Nothing exists that could key on refreshKey, so building a cache is a separate card.

Verification

Final head 162e6c0f01 (round 3; merge base f4ce10c89d), unless a line says otherwise. Builds and test runs went through os-verify-lock.sh. The check:* gates and eslint ran outside it, as the lock's scope prescribes. So did gen:docs, after two lock acquisitions for check:generated --fix timed out in the queue (exit 99).

  • Round 3 (162e6c0f01): service-analytics 136/3185 (includes the new joined-cube pin), typecheck clean; runtime REST pin 1/3; spec src/migrations 3/161; migrate-meta-engine-guidance.test.ts 3/3; spec --project repo 43/761. Lit/dark: with the fill ablated, the pin file goes 7 red (the 6 prior bucketing and narrowing cases plus the joined pin), and the probe's E1 to E7 and R1 each answer 200; on head each is refused. dispatch-gates --ran: 115 derived / 115 run / 0 NOT MEASURED, 114 exit 0, 1 exit 1 (check:platform-checklist, not a PR gate). Regenerated artefacts vs origin/main: registry.ts +62/-0 (the entry block only), spec-changes.json and the upgrade guide identical. Driver-free merge-tree probe against 2473e26875: clean, and the migration and liveness checks are green on the merge tree.
  • Round 2 (257ab1bc92): migrate-meta-engine-guidance.test.ts 3/3; spec --project repo 43/761; spec src/migrations 3/161; check:migration-registry, check:spec-changes, check:upgrade-guide, check-adr-0087-registration green; dispatch-gates --ran 115 derived / 115 run / 0 NOT MEASURED, 114 exit 0, 1 exit 1 (check:platform-checklist, inputs equal to merge base 3f45b6cc13). The lines below are round 1's, at d665865d5b.
  • Builds (①):
    • pnpm --filter '@objectstack/service-analytics...' build: exit 0.
    • turbo run build --filter='@objectstack/runtime^...': 29/29.
    • pnpm --filter @objectstack/spec build, after the describe edit: exit 0.
  • Tests (②):
suite files tests result
service-analytics, whole package 135 3178 pass
new service-door file — 13 included above
spec --project local 575 16917 (+1 todo) pass
spec --project repo 42 745 pass
runtime: the new REST pin plus the 2 sibling harness files that consume service-analytics 3 24 pass
rest: the 7 files that import service-analytics 7 86 pass (at 958251b6ac; service-analytics src is unchanged since)
  • Typecheck is clean for spec, service-analytics and runtime. Runtime's includes check:test-typecheck, and the new file adds no debt.
  • tsc --listFiles puts the new service test in service-analytics' program.
  • Two consumers were not run, and both are unaffected by construction because their cubes declare no format and no time dimension: packages/client analytics-automation-json-erasure.test.ts, and the dogfood analytics files, which declare neither key.
  • Ablations (predicted before each run; every leg restored to the HEAD blob with git diff HEAD empty):
mutation suite predicted observed at
format early-return (9d77adcd798f → 0d48cfde9469) service door 4 red 4 red, 8 green 9bf3b3b0b4
format early-return REST, through dist/ 1 red 1 red, 2 green 9bf3b3b0b4
granularity early-return (9d77adcd798f → 94b1bc63165a) service door 6 red 6 red, 7 green 958251b6ac
granularity early-return REST, through dist/ 2 red 2 red, 1 green 9bf3b3b0b4
  • Both REST legs were rebuilt, then checked with ablation-dist-preflight (marker present in 2 built files). Each restore leg was rebuilt again and checked --absent, with the tree clean.
  • Gates:
    • dispatch-gates --commands: 111 derived. --ran with exit codes: 111 run, 0 NOT MEASURED. 109 exit 0.
    • Two exit 1, both pre-existing. Their inputs are byte-identical to the merge base 1322cc72c:
      • check:platform-checklist: areas/identity-auth.json cites auth-plugin.ts#twoFactor, which is absent;
      • check:docs-transcript-drift: 4 CLI transcripts print "author-time rules (47)", and the count derives to 46.
    • check:liveness: analytics_cube 27 classified (live 20, dead 7), with the state-counts current.
    • check:generated: all 15 artifacts up to date.
  • Lint, narrowed:
    • eslint --no-inline-config --format json on the 4 changed .ts files: 4 files, 0 errors, 0 warnings.
    • The other 4 changed paths (.md / .json) are reported by eslint itself as "File ignored because no matching configuration".
    • Invariance: eslint.config.mjs never enables type-aware linting (no parserOptions.project), so this diff cannot move a verdict on an untouched file.

Acceptance notes

  • File-surface amendments to claim 5886559774, each forced by the claim's own items:
    • the analytics_cube Notes cell in packages/spec/liveness/README.md. It is the prose half of the state table whose shard the claim names, and it named both keys as dead.
    • packages/runtime/src/analytics-authored-cube-format-granularity.test.ts, the REST pin the claim asks for "where the analytics harness reaches". It is a new file, and the runtime harness drives the real dispatcher route.
    • the generated content/docs/references/data/analytics.mdx, which the describes regenerate.
    • the D3 entry, the regenerated migrations/registry.ts and the changeset marker, per seat note 5889752648.
  • packages/services/service-analytics/src/preview-evaluator.ts still open-codes the single-entry rule (dim.granularities?.length === 1) on the draft-preview path. That makes it a third spelling beside declaredDefaultGranularity. It is outside this surface; carrier: 承接者:无.
  • examples/app-showcase/src/data/analytics/showcase.cube.ts authors done_rate: { format: 'percent' }. That named style now reaches fields[].format verbatim, and a numeral-pattern renderer does not read it as a percentage. The spec describe now teaches the pattern vocabulary. The example's value is outside this surface and is reported to the seat.
  • Carried from stage 1 and unchanged here: the open-core os serve artifact-fallback boot threads no analyticsCubes.

Generated by Claude Code

…anularities reach the query doors (wip)

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…ty at the query doors, with dataset controls

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
… over POST /analytics/query and /analytics/sql

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…es are live; pin the declared narrowing; changeset

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…cribe what the analytics service does with them

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…be-format-granularities

# Conflicts:
#	packages/spec/liveness/README.md
…e-granularity-default-enforced

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-analytics, @objectstack/spec, touching 11 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/liveness/README.md, packages/spec/liveness/analytics_cube.json, packages/spec/liveness/state-counts/analytics_cube.md), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/data-api.mdx (via DimensionSchema (symbol, a top-level const), MetricSchema (symbol, a top-level const))

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

  • content/docs/releases/v14.mdx (via generateSql (symbol, a method of class AnalyticsService))
  • content/docs/releases/v17/17-2.mdx (via MetricSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-5.mdx (via generateSql (symbol, a method of class AnalyticsService))

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
  • 3 changed file(s) yielded no anchor (packages/spec/liveness/README.md, packages/spec/liveness/analytics_cube.json, packages/spec/liveness/state-counts/analytics_cube.md) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

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 6bff748bbd484f00906c8d9a58f5a3621a20c2e6 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6bff748bbd484f00906c8d9a58f5a3621a20c2e6

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 257ab1bc9287fb746279ea387a631eb389fb1a67
Local-runs: none

Read: card #20282 (body and all 24 comments; in particular 5886559774, 5889706157, 5889752648, 5890859496, 5891877761), PR #20635 (body, its one bot comment, the 12-file list) and its net diff against main at the merge base 3f45b6cc13 (12 files, +809 / -28, equal to the file list), the 42 check-runs on the head (read once, all concluded), and the code the diff touches at the head: analytics-service.ts, dataset-executor.ts, both strategies, preview-evaluator.ts, plugin.ts, the migrations registry and its three generators, check-adr-0087-registration.mjs, check-changeset-no-major.mjs, the conversion cube-sub-day-granularities-removed, and the workflows that carry the gates named below. Nothing built, run or re-run.

① Derived judgments

Enforcement, judged from the readers at the head.

  • measures.format: RIGHT. analytics-service.ts#withDeclaredMeasureFormats runs at the one return seam of queryIn (line 1627), inside the strategy loop, so a measure column is described whichever of NativeSQLStrategy (priority 10), ObjectQLStrategy (20) or FallbackDelegateStrategy (30) answered, including after a RAW_SQL_UNSUPPORTED fallback, which re-enters the same return. query() and queryDataset() both reach queryIn; on the dataset path the value read is the compiler's copy (dataset-compiler.ts:679) and enrichResultColumns (line 2174) writes only when f.format == null, so that door is byte-unchanged. Resolved through the measures bag alone (declaredMemberEntry(..., 'measure')), never replacing a value already present, copy-on-write. getMeta (line 2289) is untouched, as ruling Q3 A orders. The contract member exists (contracts/analytics-service.ts:78).
  • dimensions.granularities: RIGHT. dataset-executor.ts#declaredDefaultGranularity is the single reading (type === 'time' and exactly one entry; never an allow-list) and granularityOf now calls it (line 1364). withDeclaredGranularityDefaults runs on queryIn (line 1603) and generateSql (line 2333) before ensureCube, callCtx and strategy selection; a stated granularity wins; an entry carrying only a dateRange for an ungrouped dimension stays a filter; a grouped dimension with no entry gets one in its own spelling. Parity with DatasetExecutor.buildQuery's scoping holds by construction: the dataset path fills first, so the door-side fill finds the default present and returns query itself.
  • Readers left the old way: none on an authored cube. preview-evaluator.ts:698 open-codes the same rule, but that evaluator is reached only from queryDataset with previewDrafts over compiled.cube, which an authored cube never is; for a compiled cube the rule is identical (drift risk only, as flagged). The three five-entry mints (cube-registry.ts:143, analytics-service.ts:3002, dataset-compiler.ts:647) state no default under this reading, so ad-hoc and inferred cubes are unchanged.

The narrowing: arm RIGHT, class WRONG (under-enumerated). This is the FAIL.

  • Mechanism, confirmed: NativeSQLStrategy.canHandle declines any query carrying a timeDimensions[].granularity (line 153), so a query the fill buckets leaves the native path for ObjectQLStrategy. The custom-SQL refusal is real: resolveMeasureAggregation throws invalidMemberError (INVALID_FIELD / 400) for EXPRESSION_METRIC_TYPES = number / string / boolean (line 1484). The filled query is the same object a hand-stated granularity: 'month' produces, so byte-identity holds (pinned as DECLARED NARROWING).
  • But the engine path refuses more than custom-SQL measures, and every one of its refusals is reached by the same re-route. ObjectQLStrategy.planCrossObject (line 872), called from both execute (284) and generateSql (436), throws INVALID_FIELD / 400 for a cross-object measure or where field (a dotted path whose alias resolves to a joined object, isCrossObjectField line 698), for bucketing a cross-object time dimension, and for a non-recombinable measure (avg / count_distinct, line 1039) beside a cross-object dimension. NativeSQLStrategy serves all of these: qualifyAndRegisterJoin (line 715) lowers identifier paths into joins, and joins is threaded to the dimension, measure and filter compilers alike (lines 424, 460, 473 and following). So an authored cube that declares joins and a single-interval time dimension, queried by that dimension without a stated granularity together with any cross-object member, answers 200 before this diff and 400 after it. That is a second narrowed class, and the changeset's BREAKING sentence, the D3 entry's reason and acceptanceCriteria, and the ledger row note all leave it unnamed (each says the engine path "refuses a custom-SQL measure"). joins[].name is a live row of this ledger, so the shape is an authored one.
  • The raw-SQL-only host is under-stated the same way. There NativeSQLStrategy declines every bucketed query and no strategy remains, so count grouped by the single-interval dimension also moves from 200 to "No strategy can handle query" (line 3053), not only the custom-SQL row the PR body's table measured; the changeset's "such a query" inherits the custom-SQL referent from the sentence before it. Reachable only through a hand override of queryCapabilities (plugin.ts:512 derives both flags from the bridges), and byte-identical to the hand-stated request there too, but the class is every newly bucketed query.
  • Neither omission is a mis-measurement of what was probed: the probe cube had no joins, and the host row had one measure. The remedy is textual plus one pin. State the class as what the engine aggregate path cannot evaluate (custom-SQL measures; and, on a cube that declares joins, a cross-object measure, where field or bucketed time dimension, and a non-recombinable measure beside a cross-object dimension), each 400 INVALID_FIELD equal to the hand-stated request, and that a raw-SQL-only host refuses every such bucketed query, in the changeset, the entry (then regenerate registry.ts) and the row note; and pin one joined-cube case beside DECLARED NARROWING (200 with the fill ablated, 400 on head, envelope equal to the hand-stated request). Stage 1's round-1 record failed on this shape, an undeclared class of the same mechanism.

Public-surface changes.

  • @objectstack/spec: two .describe() strings (regenerated into content/docs/references/data/analytics.mdx; check:docs green), one new MIGRATIONS_BY_MAJOR[18].semantic entry, two ledger rows to live, the state-counts shard 18 / 9 to 20 / 7. Additive; no export moves (TypeScript Type Check and Type Check · source gates green carry check:api-surface and check:authorable-surface). RIGHT.
  • @objectstack/service-analytics: declaredDefaultGranularity is a new export of dataset-executor.ts but not re-exported from index.ts, so package-internal. The behaviour change is the widening plus the narrowing above. RIGHT except for the class statement.
  • @objectstack/runtime: one new test file; nothing published. RIGHT to carry no changeset.
  • GET /analytics/meta: unchanged (ruling Q3 A). RIGHT. Compiled datasets on POST /analytics/dataset/query: unchanged by construction. RIGHT.

The D3 entry 18.analytics-cube-single-granularity-default-enforced.ts.

  • Shape: the stage-1 entry's (id / surface / replacement / reason / acceptanceCriteria, a leading comment block, no backticks in surface). ADR-0087 at this head does not spell "form D" anywhere (grep at the head, no hit), so the shape is judged against the precedent the seat named, and it matches. No tracker id in the file (a hash followed by four or five digits: none). RIGHT.
  • Text against shipped behaviour: the fill rule, the two doors, the stated-wins / unlisted-not-refused / two-or-more-no-default clauses, and the conversion claim are all TRUE. conversions/registry.ts:7731 cube-sub-day-granularities-removed (toMajor 18) filters second / minute / hour out of granularities and keeps the rest, so a list of one sub-day and one coarser interval becomes a one-interval list, as the entry says. The refused class is the one gap named above: WRONG on that clause only.
  • Channel: the entry file plus the generated block in registry.ts (+47, the entry verbatim with its comment, inside the markers; check:migration-registry in Lint & Repo Gates is green, which is the currentness proof). spec-changes.json and docs/protocol-upgrade-guide.md unchanged, and the claim that neither projects major-18 entries yet is TRUE: PROTOCOL_MAJOR is 17 (protocol-version.ts), both generators loop from the floor 16 to PROTOCOL_MAJOR, the guide holds only a ## Protocol 16 to 17 section, and neither file contains the stage-1 id analytics-cube-public-default-visible-enforced either. The changeset's adr-0087 marker (an HTML comment) reads registered analytics-cube-single-granularity-default-enforced, the registered id, which is new in the diff, so registered is the honest disposition; Check Changeset, which runs check-adr-0087-registration.mjs --base MERGE_BASE, is green. RIGHT.

Liveness shard and README. Both rows live, evidence as file#symbol, a producer naming the CLI thread, verifiedAt 2026-09-29. The seven dead rows in the JSON are description, measures.name, measures.description, dimensions.name, dimensions.description, refreshKey.every, refreshKey.sql, which is the README row's own enumeration (2 + 3 + 2). The 27 classified / 20 live / 7 dead reading is the gate's (Spec property liveness green; its invariant is byStatus equal to the shard) and is not re-derived here. The round-2 hand-resolved README conflict lost nothing of main's: the net diff against the merge base touches exactly one README row, the analytics_cube Notes cell; no connector row appears in it. RIGHT.

Showcase and preview. examples/app-showcase/src/data/analytics/showcase.cube.ts authors format: 'percent' and declares no granularities, so neither the narrowing nor a rendering change reaches it through this diff: the value is now relayed verbatim and a numeral-pattern renderer still shows a plain number, as it did with no format at all. Not a defect introduced here; stage 3 carries the value. preview-evaluator.ts:698: unreachable by an authored cube (above). RIGHT to note and not fix here.

Gate coverage, from the 42 check-runs on the head (read once): 38 success, 4 skipped, 0 failed, 0 not concluded. Skipped: Console Pin Gate, Packed-tarball smoke (opt-in), and the second run's Auto Label / Check PR Size (each green on the first run). Families answered: Lint & Repo Gates (check:migration-registry and every check:* in lint.yml's lint job), TypeScript Type Check plus Type Check · source gates (check:generated --reconcile-only, check:authorable-surface, check:docs, check:spec-changes, check:upgrade-guide), Type Check · workspace (check:docs-transcript-drift, green here), Check Changeset (changeset present, ADR-0087 disposition, no-major), Spec property liveness (check:liveness, empty-state, variant-docs, strictness-ledger), Test Core 1-6 plus rollup (turbo test over the affected set, which holds the three changed packages), Build Core, Dogfood Regression Gate 1-3, Dogfood Verify CLI, Temporal Conformance, Governed Surface Queue Guard (no governed path in the file list). check:platform-checklist is by maintainer decision not a PR gate (lint.yml's note above the watchdog pin; the watchdog runs it on main), so the dev's local exit 1 on inputs equal to the merge base is main's state, not this diff's, and no check-run on this head answers it. Not established from the check-runs: whether Test Core's affected set reached @objectstack/cli, whose migrate-meta-engine-guidance.test.ts is the pin that the new entry carries no tracker id; the dev's local 3 / 3 run, twice, is the reading. PR mergeable_state read unknown at review time, with origin/main now at f4ce10c89d, past the 0cb72cfc72 the round-2 merge-tree probe was against; the queue's merge ref decides that.

② Semver level

  • .changeset/20282-analytics-cube-format-granularities-enforced.md: @objectstack/spec minor, @objectstack/service-analytics minor. Matches what the diff publishes: both packages gain behaviour or data an author reaches, neither loses an export; minor is the floor a Clause-②: yes declaration sets, and BREAKING rides minor under the launch-window convention check-changeset-no-major.mjs enforces (Check Changeset green). No changeset for @objectstack/runtime is right (test file only). RIGHT.
  • The PR's standalone Clause-②: yes (narrowing) line under Part of #20282, and the changeset's copy: the right arm; two authored keys take effect (widening) and one request class moves from answered to refused (narrowing). RIGHT.
  • The **BREAKING** sentence names the doors, the mechanism, the byte-identity with the hand-stated request, and a remedy an author can act on. WRONG only in the class it names (①); the remedy already covers the wider class.
  • The adr-0087 marker: one marker, registered plus the registered id. RIGHT.

③ Boundary flags

Round-1 report 5889706157:

  • Arm yes to yes (narrowing): adopted by the seat; judged the right arm above.
  • CubeMeta premise falsified: seat Q3 A; the diff leaves getMeta untouched. Answered.
  • File-surface amendments (the README Notes cell, the runtime REST pin, the regenerated analytics.mdx): each forced by a claim item, none inside a fence. Answered.
  • origin/main merged through os-regen-merge.sh before regeneration; gen:docs outside the verify lock; the bare -- intermediate run; the long lock hold; model-free commit trailers: process notes, none changes the diff, and CI answers the generated artifacts. Answered.
  • Q1 refreshKey: ruled C on [Decision] analytics: a cube's refreshKey has nothing to key on — build the cache for every and retire sql, keep both as authored intent, or retire both (the refreshKey half of #20282) #20637 by the maintainer (director pointer 5890859496), not this stage; the diff builds nothing for it and the two rows stay dead with the census re-dated. Answered elsewhere, consistent here.
  • Q2 multi-entry list: A; the diff implements exactly that. Answered.
  • Q3 /meta: A; implemented. Answered.
  • Q4 disposition: B; implemented in round 2. Answered, with the class gap in ①.
  • Showcase percent: stage 3 (seat); confirmed above not a defect this diff introduces. Answered.
  • preview-evaluator.ts:698 third spelling: Acceptance notes; confirmed unreachable by an authored cube. Answered.
  • check:platform-checklist and check:docs-transcript-drift local reds: judged from this head's check-runs above (the first is not a PR gate, the second is green). Answered.
  • Open-core os serve fallback threads no analyticsCubes: carried from stage 1, unchanged. Answered.

Round-2 report 5891877761:

  • Hand-resolved README conflict: verified lossless (①). Answered.
  • Merge-tree probe against 0cb72cfc72, no second merge: main has moved since; the queue's merge ref decides. Answered, not re-probed.
  • The discarded first probe worktree, the lock timeouts and the restart re-runs: process; nothing in the diff depends on them. Answered.
  • Generators left spec-changes.json and the guide unchanged: verified true (①). Answered.
  • pr_body_lines: all four are in the PR body as read (the disposition bullet, the round-2 registration bullet, the 257ab1bc92 head line with the round-2 verification line, the Acceptance-notes amendment). Answered.
  • open_questions: none.

Nothing is escalated: the one FAIL reason is settled from the code and has a bounded remedy.

FAIL reason (one): the declared narrowing names one refused class where the re-route exposes the engine path's whole refusal set, namely cross-object members on an authored cube with joins (400 INVALID_FIELD from planCrossObject) and every newly bucketed query on a raw-SQL-only host, in the changeset's BREAKING sentence, the D3 entry and the ledger row note, and none of it is pinned. Amend the three texts, regenerate registry.ts, add one joined-cube pin.

Implemented-by: claude/issue-20282-cube-format-granularities
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: FAIL


Generated by Claude Code

…default bucket reaches; pin the joined-cube case

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…f the declared narrowing

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 162e6c0f01ff5aa17223905ca292e7c32d0f1431
Local-runs: none

Round 3, re-judging the one FAIL reason of the round-2 record 5892945784 (head 257ab1bc92). Read: card #20282 (body and all 26 comments; in particular the stage-2 claim 5886559774, the seat answers 5889752648 with Q2 A, Q3 A, Q4 B, the round-3 order 5892981487, the reports 5889706157, 5891877761 and 5894656066, and the director pointer 5890859496 to the maintainer's ruling 5890724395 on #20637, letter C, which takes refreshKey out of this card), PR #20635 (body as patched from the round-3 pr_body_lines, its two comments, the 12-file list) and its net diff against main at the merge base f4ce10c89d (12 files, +908 / -28, equal to the file list), the 42 check-runs on the head (read once, all concluded), and the code the diff touches at the head: analytics-service.ts, dataset-executor.ts, objectql-strategy.ts, native-sql-strategy.ts, cross-object-rebucket.ts, read-scope-sql.ts, plugin.ts, the migrations entries/README.md, build-migration-registry.ts, check-adr-0087-registration.mjs, the CubeSchema join contract text, and the workflows that carry the gates named in ③. Nothing built, run or re-run; git object reads and API reads only.

① Derived judgments

The refusal set, re-derived from the code at the head, not from the dev's prose. A newly bucketed query is one where withDeclaredGranularityDefaults fills a granularity for a grouped time dimension whose cube dimension lists exactly one interval. Between that fill and the engine call, this is everything that can refuse it:

  • queryIn / generateSql before strategy selection (assertCubePublic, token resolution, ensureCube, callCtx with its read admission and source-field gates): all run on the filled query, which is the same object a hand-stated granularity produces, so any refusal there is the hand-stated request's own and not a member of the new class. Path-neutral by construction. RIGHT to leave unenumerated.
  • NativeSQLStrategy.canHandle (line 153) declines any query carrying a timeDimensions[].granularity, so the filled query leaves the raw-SQL path. ObjectQLStrategy.canHandle admits it whenever objectqlAggregate and executeAggregate are present.
  • ObjectQLStrategy.execute (and generateSql, which calls the same two resolvers): resolveMeasureAggregation throws invalidMemberError (INVALID_FIELD / 400) for a declared measure whose type is in EXPRESSION_METRIC_TYPES = number / string / boolean (line 1484); its suffix-inference arm and the count fallback never throw. dateRangeBounds does not throw. The filter lowering's own throws (renderFilterNodeSql, convertFilter default arm) are closed-vocabulary drift errors unreachable by a caller, because filter-normalizer.ts refuses an unmapped operator on both strategies alike.
  • planCrossObject (line 872), reached from execute (284) and generateSql (436), throws INVALID_FIELD / 400 for, in order: a timeDimensions entry whose dimension resolves cross-object, judged on every entry whether it carries a granularity or only a dateRange (first loop); a cross-object measure or a cross-object where field (the nonDim arm, param measures or where); a cross-object leaf of a dataset's definition-level filter (origin dataset-filter); a cross-object leaf of one dataset measure's own filter (origin measure-filter); a cross-object dimension traversing more than one relationship; and, once a single-hop cross-object dimension is in the plan, any measure whose method is outside RECOMBINABLE_METHODS (cross-object-rebucket.ts), that is avg or count_distinct. isCrossObjectField keys on a dotted resolved field whose first segment resolves through cube.joins[alias].name (or the alias itself) to something other than the base object.
  • The two dataset-definition arms cannot fire on an authored cube: filterMemberView gets its dataset-filter and measure-filter origins only from ctx.getDatasetScope(cube), and analytics-service.ts:1309 answers that from scope.getCompiledDataset(cubeName) alone, undefined otherwise. An authored cube (AnalyticsServiceConfig.cubes) never becomes a CompiledDataset. The dev's statement is TRUE.
  • withReadScope and executeCrossObject (line 1184) run the four read-scope guards (assertReadScopeCannotVacate, assertReadScopeComparandsRunnable, assertReadScopePlaceholdersResolvable, assertReadScopeAdmittedByEngine). Each refuses a deployment's policy shape at the fail-closed 5xx tier (readScopeCompileError), and the code's own notes record that NativeSQL and the echo refuse the same scopes (read-scope-sql's emptied-membership folds are polarity-dependent at the lowering site itself: $in: [] folds to 1 = 0 one arm from $not, and $nin: [] folds to 1 = 1 (constant TRUE) #13571, analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367). Not a member of the request or the cube, so not a member of the class the changeset defines. Judged outside, and rightly unnamed.
  • resolveStrategy (line 3053) throws "No strategy can handle query" when no strategy admits the filled query: on a host whose queryCapabilities offers nativeSql without objectqlAggregate and that has no fallbackService, every newly bucketed query, a plain count included. plugin.ts:512 derives both flags from the bridges it wired, so this is a hand-override host only.

The three texts against that set. The changeset's **BREAKING** sentence, the D3 entry's replacement / reason / acceptanceCriteria (and the regenerated registry.ts block, which carries the entry's comment and literal verbatim), and the analytics_cube dimensions.granularities row note each name: the custom-SQL measure with its type triple; and, on a cube whose members resolve through its joins, a measure or where field over a joined object, a timeDimensions entry over a joined object, bucketed or a dateRange window (so a one-interval time dimension over a joined object is refused too), a dimension traversing more than one relationship, and an avg / count_distinct measure beside any dimension over a joined object; each byte-equal to the hand-stated request; the raw-SQL path serving all of them; and the raw-SQL-only host consequence for every newly bucketed query, plain count included. The entry's reason and the row note also say the two dataset arms cannot fire on an authored cube. That is the set above, member for member: no member missing, none overstated. RIGHT, all four carriers. The round-2 FAIL reason is settled.

Three boundaries I weighed and did not count against the class, recorded so the next reader can disagree with the reasoning rather than re-find it:

  • isCrossObjectField keys on the dotted path, not on a joins declaration: a cube with no joins key whose member sql is account.balance is served by NativeSQL through qualifyAndRegisterJoin's same-name fallback (LEFT JOIN "account") and refused by the engine path just the same. The spec's own contract text (CUBE_JOIN_DERIVED_ON: "joins.alias.name names the joined object and is the whole of the contract") makes "members resolve through its joins" the right description of the authored shape; the joins-less same-name cube is a legacy tolerance of one strategy, not a contract member.
  • A raw-SQL-only host that ALSO holds a fallbackService (the plugin captures a pre-registered analytics service, plugin.ts:296) routes the bucketed query to FallbackDelegateStrategy rather than to the "No strategy" exit. The sentence conditions itself on the override alone; that second hand-wired composition is outside its condition and its outcome is a delegation, not a refusal.
  • What the engine or driver itself refuses after executeAggregate is called (for instance a dimension whose sql is an expression, which the AST carries as a field name) is the driver's contract, reached identically by a hand-stated granularity, and outside "between canHandle and the engine call".

The new pin DECLARED NARROWING, joined cube (cube-authored-format-granularity.test.ts): the cube is authored plus account_balance: { type: 'sum', sql: 'account.balance' } and joins: { account: { name: 'crm_account' } }, parsed through CubeSchema, so the member is cross-object through a real declaration (crm_account is not shop_order). Both strategies are wired. The pin asserts INVALID_FIELD / 400 on the default-bucketed request, then full envelope equality with the hand-stated request over code, status, message, member, param, cube and the sorted own-key list, that neither executeAggregate nor executeRawSql was reached, and the control that the same measure grouped by the two-interval shipped_at still answers on raw SQL with a JOIN in the statement. With the fill removed, the default request carries no granularity, NativeSQLStrategy.canHandle admits it, the raw-SQL bridge answers, byDefault resolves to a result, and the first expectation fails on code: undefined; the sqls assertion fails with it. The dev's dark leg reads exactly that (7 red of 15, the joined pin among them, failing on { code: undefined, status: undefined }). A sum measure cannot be refused by resolveMeasureAggregation, so the refusal source is planCrossObject's cross-object-measure arm by construction. The refusal message is not literally asserted, only equality with the hand-stated one; sufficient, since the pin's claim is byte-identity, not attribution. RIGHT.

Nothing else moved in round 3. Blob comparison between 257ab1bc92 and the head over the 12 files: analytics-service.ts (9d77adcd798f), dataset-executor.ts (f6e0d7e93de5), analytics.zod.ts, analytics.mdx, the runtime REST pin, the liveness README and the state-counts shard are byte-identical; the changeset, the D3 entry, registry.ts, analytics_cube.json (the granularities row note; status, evidence and producer unchanged) and the service pin file changed. That is the round-3 order's list, and no src behaviour. RIGHT.

The merge of origin/main f4ce10c89d (4c8b38a5cc) lost nothing of main's. Against the merge base 3f45b6cc13 of its two parents: 220 paths moved on main, 12 on the branch, one overlap (registry.ts). Every main-only path's blob in the merge equals main's (0 mismatches); every branch-only path's blob equals the branch's (0 mismatches); registry.ts was then regenerated in e1383be04d, and the net diff against the merge base is +62 / -0, the one entry block. check:migration-registry is green on this head (Lint & Repo Gates), which is the currentness proof for the regenerated region. RIGHT.

Enforcement, unchanged from round 2 and re-read at the head. withDeclaredMeasureFormats at the one return seam of queryIn (line 1627), measures bag only, never replacing a value, copy-on-write; getMeta untouched (Q3 A). withDeclaredGranularityDefaults on queryIn (1603) and generateSql (2333) before ensureCube and callCtx; declaredDefaultGranularity is the single reading and granularityOf calls it (1364); a stated granularity wins, an unlisted one is not refused, two or more state no default (Q2 A), a window-only entry stays a filter. RIGHT. declaredDefaultGranularity is exported from dataset-executor.ts and not re-exported from index.ts, so the package's public surface does not move. RIGHT.

Public-surface changes. @objectstack/spec: two .describe() strings (regenerated into analytics.mdx), one new MIGRATIONS_BY_MAJOR[18].semantic entry, two ledger rows dead to live, the shard 18 / 9 to 20 / 7. @objectstack/service-analytics: the widening plus the declared narrowing, internal helper only. @objectstack/runtime: a test file. POST /analytics/dataset/query: unchanged by construction (the dataset path fills first, the door-side fill finds the default present). GET /analytics/meta: unchanged. RIGHT.

The D3 entry. File entries/semantic/18.analytics-cube-single-granularity-default-enforced.ts, so it lands in step18 (the registry.ts hunk's context is const step18: MigrationStep), sorted between analytics-cube-public-default-visible-enforced and analytics-date-range-array-two-bounds-required as build-migration-registry.ts derives order from the id. Registered through the designed channel: one entry file, the generator's marked region, no hand edit, no index. Tracker ids in the file: none (a hash followed by four to six digits: zero hits; the two ids it names are entry ids, and migrate-meta-engine-guidance.test.ts pins the printed block against # plus four or five digits). The conversion claim is TRUE at the head: conversions/registry.ts:7731 cube-sub-day-granularities-removed strips sub-day intervals, so a two-entry list of one sub-day and one coarser interval becomes a one-interval list. spec-changes.json and docs/protocol-upgrade-guide.md: absent from the 12-file net diff, and byte-equal to origin/main 2473e268 as well. check:spec-changes and check:upgrade-guide are green (Type Check · source gates). RIGHT.

Ledger. Both rows live with file#symbol evidence and the CLI-threading producer; the _note sentence rewritten; the granularities row note states the class. The README Notes cell (unchanged since round 2) says the refreshKey block was "re-measured 2026-09-29" while the two refreshKey rows keep verifiedAt 2026-09-17 in the JSON: a prose-versus-row date mismatch, moot under #20637 C, which retires both rows with the key. Not this diff's claim. RIGHT to leave.

② Semver level

  • .changeset/20282-analytics-cube-format-granularities-enforced.md: @objectstack/spec minor, @objectstack/service-analytics minor. Matches what the diff publishes: both packages gain behaviour an author reaches, no export is removed or narrowed, and BREAKING rides minor under the launch-window convention that check-changeset-no-major.mjs enforces (Check Changeset green, twice). No changeset for @objectstack/runtime (a test file only) is right. RIGHT.
  • Clause-②: yes (narrowing) on the PR body's second line and in the changeset: two authored keys take effect (the widening), one request class moves from answered to refused (the narrowing). The right arm, and the changeset names it. RIGHT.
  • The **BREAKING** sentence carries the doors, the mechanism, the whole class (①), the byte-identity, the raw-SQL-only host consequence and a remedy an author can act on. RIGHT.
  • The adr-0087 marker (an HTML comment) reads registered analytics-cube-single-granularity-default-enforced, one marker, an id new in the diff that resolves in registry.ts; the gate's parser at check-adr-0087-registration.mjs:1924 reads that shape and Check Changeset runs it against the merge base. RIGHT.

③ Boundary flags

Round-3 report 5894656066:

  • open_questions: none. out_of_scope_findings: none.
  • Deviation, origin/main moved after the merge (to 2473e26875) and no second merge: main's registry.ts hunks since f4ce10c89d sit at lines 5664, 9298 and 13438, none adjacent to this entry's insertion near 6416, and the PR reads mergeable: true, mergeable_state: clean; the queue's merge ref decides. Answered.
  • Deviation, lock queue timeouts and background waits; deviation, the probe file kept out of the tree (the net diff holds exactly the 12 files, no probe); deviation, the extra docblock-only commit 162e6c0f01: process, none changes the diff. Answered.
  • pr_body_lines: all five are in the body as read (the method and cube lines, the eleven-row table, the "Every 400 is byte-identical" bullet with the class, the round-2 registration bullet's round-3 clause, the round-3 head line and verification block). Answered.
  • The lit / dark legs are recorded, as the order required (item 3). Answered.

Round-2 review 5892945784 and round-2 report 5891877761: the one FAIL reason is settled in ① (the three texts and the pin); the README conflict resolution and the generator no-op on the two projections were verified there and hold at this head. Answered.

Round-1 report 5889706157, each flag re-checked at this head: the arm yes to yes (narrowing), adopted and right; the CubeMeta premise falsified, Q3 A, getMeta untouched; Q1 refreshKey, ruled C on #20637 by the maintainer, out of this card, nothing built for it here and its rows untouched; Q2 A, implemented; Q4 B, implemented and now complete; the file-surface amendments (README cell, runtime pin, analytics.mdx, then the entry, registry.ts and the marker), each forced by a claim item, none inside a fence; the showcase format: 'percent', relayed verbatim, stage 3 (and the format row note says so); preview-evaluator.ts:698, unreachable by an authored cube, noted as drift risk; check:platform-checklist, not a PR gate by maintainer decision (lint.yml's note above the watchdog pin) and its inputs unmoved by this diff; check:docs-transcript-drift, green in Type Check · workspace; the open-core os serve fallback threading no analyticsCubes, carried from stage 1, unchanged. All answered.

Docs-drift bot 5889805708: names content/docs/api/data-api.mdx via the two schemas; that page already sends clients to fields[] for format, which is what the new describe and the changeset say, so no edit is owed; the three release-owned pages are untouched. Answered.

Gate coverage, from the 42 check-runs on the head, read once: 38 success, 4 skipped, 0 failure, 0 not concluded. The roster equals round 2's 42; the eight second runs at 16:50Z are the pr-automation jobs re-fired by the body edit, and two of them (Auto Label, Check PR Size) are skipped with their first runs green, neither a required context. Skipped otherwise: Console Pin Gate (path-filtered on .objectui-sha and console paths, none touched) and Packed-tarball smoke (opt-in). All seven required contexts are success: Lint & Repo Gates (check:migration-registry and the lint job's check:* family), TypeScript Type Check with its four lanes (Type Check · source gates carries check:generated --reconcile-only, check:authorable-surface, check:docs, check:spec-changes, check:upgrade-guide; Type Check · workspace the per-package typecheck and check:docs-transcript-drift), Test Core 1 to 6 and rollup (turbo test over the affected set, which holds the three changed packages and, through the @objectstack/spec dependency, @objectstack/cli, whose guidance pin is a queue-population file, not a nightly-tier name), Dogfood Regression Gate 1 to 3 and rollup, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard (no governed path in the file list). Check Changeset (changeset present, ADR-0087 disposition, no-major) and Spec property liveness (check:liveness, check:empty-state, check:variant-docs, check:strictness-ledger) are green advisory contexts. No red is this diff's, and nothing needed for enqueue is absent.

Nothing is escalated.

Implemented-by: claude/issue-20282-cube-format-granularities
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 17:08
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit c8dd8dd Sep 29, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20282-cube-format-granularities branch September 29, 2026 17:31
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

Development

Successfully merging this pull request may close these issues.

2 participants