Skip to content

feat(spec)!: an analytics cube member's sql is a column reference, and the showcase done rate moves to its dataset (#20943) - #20998

Merged
os-justin merged 8 commits into
mainfrom
claude/issue-20943-cube-sql-identifiers
Oct 1, 2026
Merged

os-justin merged 8 commits into
mainfrom
claude/issue-20943-cube-sql-identifiers

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Closes #20943
Clause-②: yes (narrowing)

Ruling D on #20943 (comment 5921156712, execution parameters), dispatched by the claim 5921298734: an analytics cube member's sql is a column reference, and a SQL expression there is refused at parse with a prescription naming the ADR-0021 dataset form. The showcase's one expression member, done_rate, moves to its dataset in the same PR.

What changes

@objectstack/spec, data/analytics.zod.ts

  • MetricSchema.sql and DimensionSchema.sql (every member of a cube's measures and dimensions) admit exactly the accept set the execution parameters name: a bare identifier (amount), a dotted identifier path (account.amount, account.owner.region), and '*'. Any other value is refused at measures.METRIC.sql / dimensions.DIMENSION.sql with code invalid_format. That covers a CASE expression, an aggregate or a ratio of aggregates, a quoted or $-prefixed spelling, an empty string and a broken path. A column reference parses byte-identically to before.
  • The identifier half is the pattern the readers already use to tell a column path from an expression: IDENTIFIER_PATH in native-sql-strategy.ts, and the field gate's bare-identifier / identifier-path pair. So the contract admits exactly the values those readers resolve to fields.
  • The rule is a .regex(), not a refinement, so the published JSON Schema carries it as a pattern. A first cut used refinements, and the build's dropped-refinement gate refused it: a JSON-Schema-validated document would have been judged differently from the parse. The pattern closes that gap, and dropped-refinements.baseline.json is untouched.
  • Each prescription opens with the contract sentence. It names the dataset form: a measure with its own structured filter for a conditional count or sum, and derived: { op, of: [...] } over named measures for a ratio, sum, difference or product. It also states the ratio's 0–1 scale. The dimension prescription says that a CASE bucket has no expression form in either layer.
  • Neighbouring prescriptions no longer offer "fold the condition into the metric's own sql expression" as a live channel. This covers the retired metric filters guidance, the analytics query filters guidance, the metric-filters-removed conversion summary, its D3 entry cube-metric-filters-retired, and its step-18 rationale fragment. All of them are unreleased major-18 text.

ADR-0087. The D3 entry cube-member-sql-expression-retired (migrations/entries/semantic/) and its step-18 rationale fragment were written after merging a main that contains #20953, which landed as c6b3a01d5d. registry.ts was regenerated by gen:migration-registry, never edited by hand. There is no D2 conversion, because an expression has no mechanical rewrite into a dataset. There is no RETIRED_KEYS_BY_MAJOR row, because no key left the shape.

Liveness. The analytics_cube rows measures.sql and dimensions.sql stay live, re-verified 2026-09-30, with the narrowing recorded and the field gate's fieldsOfColumnSql added to the evidence.

Generated. Only content/docs/references/data/analytics.mdx moved. The authorable-surface, api-surface and json-schema.manifest ratchets are byte-identical, as expected for a value narrowing. spec-changes.json and the upgrade guide stay at protocol 17, so major-18 entries do not project yet, and both checks are green.

Showcase. The showcase_delivery cube loses done_rate. The showcase_task_metrics dataset gains done_count (aggregate: 'count', filter: { status: 'done' }) and done_rate (derived: { op: 'ratio', of: ['done_count', 'task_count'] }, format: '0.0%'), in the same shape as the existing paid_rate. No dashboard read the cube's done_rate, so there was nothing to re-pin there. test/gap-fill.test.ts is re-pinned: the cube's measure list, the parse of the shipped cube, and the dataset's filtered-count-over-count form plus its DatasetSchema parse.

@objectstack/service-analytics. One test and the README, nothing else:

  • cube-authored-format-granularity.test.ts built its custom-SQL fixture with CubeSchema.parse, which now refuses it. The fixture is built unparsed instead, plus one assertion that the parse refuses it. The engine-path refusal it pins is unchanged.
  • README.md no longer tells a reader to fold a per-metric condition into the metric's own sql expression. That text ships in the package, so it gets a patch line in the changeset. This file is outside the claim's declared surface; see the Acceptance notes.

The gate's stand-down branch in analytics-service.ts is not touched. #20965 remains open for it.

The fork clause

The fork clause did not fire. The one authored expression member in the tree is done_rate, and its structural equivalent exists and is measured (next section). A shape-level reading for the seat: a dimension CASE bucket has no dataset equivalent, because a dataset dimension names a field and its only bucketing is dateGranularity. No authored cube in the tree carries one. Two service-analytics gate fixtures do (dimension-source-field-gate.test.ts, where-source-field-gate.test.ts), but they are built without the parse to pin the runtime branch #20965 owns. Out-of-repo cubes are NOT MEASURED.

The measure-level filter, verified before relying on it

  • Code path. compileDataset reads m.filter into measureFilters. DatasetExecutor splits filtered measures off with splitMeasuresByFilter and runs one supplementary query per filtered measure over the base filter combined with that measure's filter. It then evaluates derived on the merged row, with ratio as a / b and null on a zero denominator.
  • Unit pins. The existing dataset-executor.test.ts cases cover a supplementary query for a measure-scoped filter and a derived measure over filtered and unfiltered dependencies, and dataset-compare-measure-filters.test.ts covers the number a reader sees. All are green in the service-analytics run below.
  • Live. An ephemeral showcase boot on a private port (pnpm dev -- --fresh, torn down after) queried the task dataset through the analytics dataset door as the seeded admin. done_count and done_rate reconciled against the raw showcase_task rows in every priority bucket and in the ungrouped total. done_rate equalled done_count / task_count, and the column carried format: '0.0%' and percentScale: 'fraction'. The cube's meta listed its three remaining measures.

Tests (final head fa979c57ed unless stated)

  • @objectstack/spec:
    • vitest run --project local src/: 544 files, 16268 passed, 1 todo.
    • --project local scripts/: 41 files, 950 passed, 1 skipped (at a32fd122dc; the second merge brought no scripts/ change under packages/spec).
    • The relevant --project repo files: 10 files, 300 passed. These are step18-rationale-merge, conversions-major18-merge, liveness/evidence, liveness/proof-registry, cube-member-inner-name-retirement, cube-refresh-key-retirement, retired-key-migrate-sentence, root-index, file-description and category-title.
    • typecheck exit 0.
  • @objectstack/service-analytics: test gave 149 files and 3449 passed; typecheck exit 0, and the edited test is in its program (--listFiles).
  • @objectstack/example-showcase: vitest run gave 29 files and 387 passed, after rebuilding its closure (60 tasks); typecheck exit 0, and the three edited files are in its program.
  • Gates:
    • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 116 families. All 116 were run, and --ran reconciled them as "116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN", each with an exit code.
    • 115 of them exited 0, including check:generated, check:liveness, check:adr-0087-registration (reads the new marker), check:changeset-no-major, check:empty-changeset, check:doc-authoring and check:nul-bytes.
    • The other one is pnpm check:platform-checklist, which exited 1 on an anchor this diff does not touch. areas/identity-auth.json cites plugin-auth/src/auth-plugin.ts#twoFactor, and that symbol is absent. This branch's bytes for packages/plugins/plugin-auth, docs/qa/platform-checklist, scripts/check-platform-checklist.mjs and scripts/symbol-anchors.mjs are identical to the merge base 05be352596, so it is main's state, not this diff's.
  • Lint, a declared narrowing: eslint --no-inline-config --format json over the 10 changed lintable files gave 10 files, 0 errors and 0 warnings.
    • The population comes from eslint.config.mjs: the **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} block. The other changed files are .md, .mdx and .json.
    • The file count comes from the JSON output.
    • Invariance: the config never enables type-aware linting (no parserOptions.project, no typed rules), so this diff cannot move a verdict on an untouched file. The whole-repo pnpm lint is CI's.

Ablations (each from the committed state; disk-verified through scripts/ablation-replace.mjs; restore proven by blob hash equal to HEAD and an empty git diff HEAD)

leg mutation result under mutation restored
A1 the member sql pattern admits anything new spec pin: 9 failed / 6 passed (every refusal, door and pattern case red) 15 / 15 green
A2 the metric filters guidance offers the sql expression channel again 1 failed (the guidance case) green
A3 the D3 id renamed in the generated registry region 1 failed (the registration case) green
A4 the showcase done_count loses its filter gap-fill: 1 failed (the dataset-form case) green
A5 the pattern admits anything, then @objectstack/spec rebuilt ablation-dist-preflight marker present in 20 built files; the service-analytics parse assertion failed (expected true to be false) rebuilt; marker absent from all 230 files; tree clean; 14 / 14 green

The expected direction was "turns red" in all five, and that is what was observed. No ablation file is left in the tree.

Acceptance notes

  • Surface breach, declared. packages/services/service-analytics/README.md is outside the claim's file surface. The dev contract's rule says published text this change makes false is fixed in the same round, and the dev contract outranks the dispatch words where they conflict. The edit is one paragraph, and the changeset carries '@objectstack/service-analytics': patch for it. The seat can drop both if it prefers service-analytics: delete the analytics field gate's stand-down on an authored cube expression member once #20943 retires raw expressions in a cube member's sql (the branch becomes unreachable) #20965 to carry the fix.
  • Not fixed here, governed (Tier H). skills/objectstack-ui/rules/dashboards.md still escalates past a dataset to "a hand-authored Cube (raw SQL / explicit joins)" (≈:78), and lists "any custom-SQL metric" as a dataset gap (≈:70). Both are now false: a cube member takes no SQL expression, and joins were already derived. The natural carrier is the separate Tier H docs PR the ruling names for ADR-0021's dated note. It is not mixed in here.
  • Not fixed here, internal. docs/qa/platform-checklist/areas/dashboards.json (the cube meta item, ≈:976, :991, :1037) names done_rate among "exactly its four measures" and as a variant. The cube now declares three. No gate reads that text. Carrier: the checklist's next revision.
  • Boundary, left as ruled. '*' is admitted on a dimension and on a non-count measure, because the execution parameters admit it on both members. Neither was ever a working query. Separately, the number / string / boolean measure types existed to carry an expression. With an identifier sql, the raw-SQL path emits the column unaggregated and the ObjectQL path refuses it, so retiring those three types is the natural follow-up. That is a decision for the seat, not something this PR does.
  • Not added: a tree-scoped text pin. The narrowing is enforced at every parse door. The one authored cube in the tree is parsed at import (defineCube) and pinned by the showcase test. The tree's other expression members are deliberate unparsed runtime fixtures in service-analytics, owned by service-analytics: delete the analytics field gate's stand-down on an authored cube expression member once #20943 retires raw expressions in a cube member's sql (the branch becomes unreachable) #20965's cleanup. A text scan would have needed a directory exclusion and a new cross-package radius.
  • tsc does not catch an expression member, because sql is still string; the parse is the judge. Stored analytics_cube rows and built artifacts that carry an expression are NOT MEASURED here.

Generated by Claude Code

…ne rate moves to its dataset

Narrow MetricSchema.sql / DimensionSchema.sql to a column reference (a bare
identifier or a dotted identifier path, and '*' on a count measure). Any SQL
expression is refused at parse with a prescription naming the ADR-0021 dataset
form. The showcase cube's done_rate expression moves to the task dataset as a
filtered count over the unfiltered count.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…ma carries it as a pattern

The accept set follows the ruling's execution parameters: an identifier, a
dotted identifier path, and '*', on a measure and a dimension alike.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…, and regenerate the registry and reference docs

The D3 semantic entry and its step-18 rationale fragment, the metric-filters
fragment no longer naming a metric's own sql expression as a live channel, the
regenerated migration registry, and the analytics reference page.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…arrowing, and the README stops prescribing a sql expression

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

github-actions Bot commented Oct 1, 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). ⚠️ 2 changed file(s) yielded no anchor (packages/services/service-analytics/README.md, packages/spec/liveness/analytics_cube.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/api/data-api.mdx (via DimensionSchema (symbol, a top-level const), MetricSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/data-engine.mdx (via account.industry (literal, a string literal in DimensionSchema))
  • content/docs/protocol/objectql/query-syntax.mdx (via account.industry (literal, a string literal in DimensionSchema))

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

  • content/docs/releases/v17/17-2.mdx (via MetricSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-4.mdx (via AnalyticsQuerySchema (symbol, a top-level const))

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
  • 2 changed file(s) yielded no anchor (packages/services/service-analytics/README.md, packages/spec/liveness/analytics_cube.json) — pages documenting those are invisible to this run
  • 7 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 9b0de7de73699771b69649bf7b507fbd2a842260 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9b0de7de73699771b69649bf7b507fbd2a842260

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

…ne_rate labels at birth

The two dataset measures the done rate moved into are declared surface under
the i18n coverage ratchet, so they carry en and zh-CN bundle entries; the
showcase's untranslated count is back at its baseline.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e6b66ecde531197df2027f40a0799b86e4c7460b
Local-runs: none

① Derived judgments

Read against main at the merge base 05be352596 (no file this PR touches has moved on main since) and ruling D's execution parameters (5921156712), which bind. Disclosure discipline held: nothing below restates a request, a field spelling or a value from the dev's private live measurement.

Accept set — right. CUBE_MEMBER_SQL admits *, or one [A-Za-z_][A-Za-z0-9_]* identifier followed by zero or more .-joined identifier hops, anchored at both ends, no whitespace. The identifier half is byte-equal to IDENTIFIER_PATH in native-sql-strategy.ts:126 and to the field gate's BARE_IDENTIFIER (/i) plus IDENTIFIER_PATH pair in analytics-service.ts:307,396, so every admitted non-wildcard value is one fieldsOfColumnSql (:416) attributes to a field the gate judges. No expression shape passes: parentheses, operators, quotes, whitespace, a leading digit, an empty string, a trailing dot and a doubled dot all fail the anchored pattern, and the JS $ anchor admits no trailing line. Quoted and $-prefixed spellings are not legitimate identifier forms: a field name is ^[a-z_][a-z0-9_]*$ (field.zod.ts:1113, object.zod.ts:1612), neither reader above admits them, and the one place that tolerated a leading $ — the ObjectQL strategy's replace(/^\$/, '') (objectql-strategy.ts:1462-1470) — is a consumer-side alias with no authored instance anywhere in the tree (docs, skills and examples grepped). Refusing them is the ruling's narrowing, not an over-reach. '*' on a dimension and on a non-count measure stays admitted, exactly as the parameters name it and the seat re-ruled (Q2, A).

Refusal and prescriptions — right, and every form they name works on main today. invalid_format at measures.METRIC.sql / dimensions.DIMENSION.sql (zod 4 .regex(), one issue per member); the published JSON Schema carries the same pattern, pinned through z.toJSONSchema. The dataset form the prescriptions name: a measure-scoped filter is DatasetMeasureSchema.filter (analyticsCarrierFilter, dataset.zod.ts:196), read by compileDataset into measureFilters (dataset-compiler.ts:701) and run by DatasetExecutor as one supplementary query per filtered measure (dataset-executor.ts:177,1274-1296); derived: { op: 'ratio', of } is computeDerived (dataset-executor.ts:271, a / b, null on a zero denominator); the ratio column is annotated percentScale: 'fraction' (analytics-service.ts:2430). The "0–1 fraction" sentence is true. The dimension prescription's "keep the bucket as a field of the object" is the formula-field route the dashboards skill already teaches. "retired in @objectstack/spec 17" is the house spelling for an unreleased step-18 retirement (twenty siblings in view.zod.ts, page.zod.ts, report.zod.ts, dashboard.zod.ts) and is true the moment the next 17.x minor ships; spec is at 17.5.0, PROTOCOL_VERSION 17.0.0.

Doors — right. getMetadataTypeSchema('analytics_cube') is CubeSchema (metadata-type-schemas.ts:283); defineStack raises STACK_SCHEMA_INVALID / 422 (stack.zod.ts); defineCube throws the prescription. All four are pinned in cube-member-sql-column-reference.test.ts with a CONTROL cube that passes every door. "tsc does not catch it" is stated and true: the key's type stays string.

Showcase migration — right. done_rate leaves showcase_delivery; showcase_task_metrics gains done_count (count, filter: { status: 'done' }) and done_rate (ratio of done_count over the dataset's existing unfiltered task_count, format: '0.0%') — the shape paid_rate already uses. Same numbers at display: the old cube column shipped points 0–100 with no percentScale (percentScaleOf answers one for a percent field only), so the pinned renderer (packages/core/src/utils/dataset-format.ts:420 at objectui db11afd49) fell back to percentDisplayValue, which leaves a value outside the open interval (−1, 1) as is; the new column is a fraction tagged 'fraction', multiplied by 100 — both print the same percentage for the same ratio, and the new one cannot be fooled at a rate under one point. Same format: 0.0% kept. The raw API number is divided by 100, disclosed in the changeset, the D3 entry, the rationale and the prescription. No dashboard, report or saved query named the cube's done_rate (grep of examples, packages, content, skills on main: the cube, its test, the checklist prose and one service-analytics fixture only). gap-fill.test.ts still tests something: the cube's measure keys, a CubeSchema re-parse of the shipped literal, the dataset's filter and derived shape with field absent on the count, and a full DatasetSchema parse through the cross-measure refinement. cube-authored-format-granularity.test.ts builds its expression fixture unparsed, adds the parse-refusal assertion, and keeps the engine-path refusal pin; ablation A5 is its direction proof.

ADR-0087 — right, and generator-produced where the generator owns it. 18.cube-member-sql-expression-retired.ts carries the four SemanticMigration fields, no backticks or pipes in surface. Its literal and leading comment appear verbatim, four-space indented, in the os-generated semantic:18 region sorted by id — after cube-member-inner-name-retired, before cube-metric-filters-retired — exactly as build-migration-registry.ts concatenates (order derived, never declared). The sequence holds: c6b3a01d5d (#20953) is an ancestor of the first merge 93d8e1f0e1, which precedes the regeneration commit a32fd122dc. STEP18_RATIONALE is hand-kept by design (its header: sorted by id, order one more than the highest); the fragment sits in its sorted slot with order: 53, the next after 52, and step18-rationale-merge holds that shape. No D2 conversion and no RETIRED_KEYS_BY_MAJOR row is right: no key left the shape, so the authorable-surface, api-surface and json-schema.manifest ratchets are byte-identical — the skill's enum-narrowing row, not a missed regeneration. The hand-kept conversions/registry.ts summary edit of metric-filters-removed (toMajor: 18) projects into neither spec-changes.json nor the upgrade guide at protocol 17, so no regeneration was owed. The edited cube-metric-filters-retired entry and fragment and the two filters guidances no longer offer the retired channel — pinned by the new test's negative matches.

Liveness ledger — one row earned, one row's note false. Both rows stay live, verifiedAt 2026-09-30, and every evidence anchor resolves on main (resolveMeasureSql :919, resolveDimensionSql :903, resolveFieldName :1462, fieldsOfColumnSql :416). The measures.sql note is accurate except that "'*' on a count measure" under-states the schema, which admits '*' on every measure type. The dimensions.sql note says "a SQL expression — a CASE bucket included — and '*' are refused at parse": FALSE at every moment, on main when this lands and after. The dimension uses the same CUBE_MEMBER_SQL; its TSDoc in the same diff says "'*' is admitted with the measure's accept set"; the PR body says so; and cube-member-sql-column-reference.test.ts pins '*' as ADMITTED on a dimension. The ledger note is the text the next auditor reads instead of re-measuring (the retirement skill's own rule), and it erases the very boundary the dev flagged as Q2 and the seat left for #21000 to narrow. BLOCKING. Fix: in packages/spec/liveness/analytics_cube.json, the dimension note reads that '*' is admitted (ruled; never a working query), and the measure note drops "on a count measure".

Other text, tested. The schema comment's "ObjectQLStrategy#resolveMeasureAggregation refused it" is over-broad: that arm (objectql-strategy.ts:1474) refuses the number / string / boolean partition only; an expression under an aggregate type was forwarded as a field name and failed downstream. "The two strategies never agreed" holds either way; not blocking. The .describe() strings, the regenerated analytics.mdx (Metric and Dimension rows, twice each), the showcase cube comment, the translations (datasets.NAME.measures.NAME.label is the declared bundle shape, translation.zod.ts:924,957, label optional on the dataset block; both claimed locales filled), the README paragraph, and the PR body's "Generated", "Showcase", "Tests" and "Acceptance notes" sections each read true against the tree. analytics-service.ts is not in the file list, so #20965's stand-down is untouched. Fork clause: no authored dimension CASE bucket exists; the two gate fixtures that carry one (dimension-source-field-gate.test.ts:503, where-source-field-gate.test.ts:713) are built without CubeSchema.parse. Out-of-repo cubes NOT MEASURED, as stated.

Check-runs on e6b66ecde5 (35, deduped by name keeping the newest started_at; none running): 33 success, 2 skipped — Console Pin Gate (no pin move) and Packed-tarball smoke (opt-in). Green include Spec property liveness, TypeScript Type Check (the eight spec artifacts, check:migration-registry, check:adr-0087-registration), Lint & Repo Gates, Check Changeset, Type Check · consumer gates (i18n coverage back to baseline after the patch round 5922922632), Test Core 6 of 6, Dogfood Regression Gate 3 of 3, Temporal Conformance. Combined status success (Vercel).

② Semver level

.changeset/20943-cube-member-sql-column-reference.md: @objectstack/spec: minor with the BREAKING banner, Clause-②: yes (narrowing), the FROM → TO migration, the one-line fix, and the adr-0087 registered marker naming cube-member-sql-expression-retired — a new id, so registered is the right arm. That matches the diff: an accept-set narrowing on a published schema with no export, key or def change, shipped minor under the launch-window convention (check-changeset-no-major; eight sibling changesets on main spell it the same way). @objectstack/service-analytics: patch is earned: README.md is in that package's files and this PR makes its old sentence false; no runtime byte changes, and the one edited test does not ship. example-showcase is private, no line owed. The PR body's Clause-②: yes (narrowing) is present and equal to the changeset's.

③ Boundary flags

Implemented-by: claude/issue-20943-cube-sql-identifiers
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: FAIL

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-10-01T01:59Z · rendered by the seat's at-tier review subagent on this head. The seat read its served tier family from the subagent transcript before posting. ⚠️ The disclosure discipline holds. FAIL accepted.


Generated by Claude Code

…mits, and the schema comment names the ObjectQL refusal's real partition

The dimensions.sql note said '*' is refused at parse; the schema admits it on
a dimension, as on any measure, and the measures.sql note no longer limits it
to a count. The count-only boundary is #21000's. The schema comment now says
the ObjectQL path refuses only the number / string / boolean partition.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ac70996345fd12a48696af01fbbb319bd593bf13
Local-runs: none

① Derived judgments

Delta re-review of the cut round the adoption 5923211985 ordered on the prior FAIL at e6b66ecde5. Read against main at the merge base 05be352596 (no file this PR touches has moved on main since — git diff --stat over the 15 paths is empty; the two main commits since that touched service-analytics, 8d329f02ee and f8178ffece, leave every symbol cited below in place, and I read those symbols at the current origin/main 2f2fa11d75). Ruling D's execution parameters (5921156712) bind. Disclosure discipline held: nothing below restates a request, a field spelling or a value from the dev's private live measurement.

The delta is exactly the cut, and nothing else moved — right. One commit, two files, 6 insertions / 3 deletions: the two note strings in packages/spec/liveness/analytics_cube.json (measures.sql, dimensions.sql) and one paragraph of the block comment above CUBE_MEMBER_SQL in analytics.zod.ts. CUBE_MEMBER_SQL is byte-identical (^(?:\*|[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)$), and no .describe() string, prescription, guidance, test, generated artifact, D3 entry, registry region or changeset line changed. The edited comment sits on a non-exported const and is projected into no published or generated artifact (grepped packages/spec, content and json-schema at the head: the paragraph appears nowhere else), so no regeneration was owed and none is missing. The ledger note is free text no check scans (check-liveness.test.ts:451 says so), so each corrected sentence is this record's to judge, and I did.

measures.sql note, sentence by sentence — true now, on main when this lands. "'*', which the schema admits on any measure": MetricSchema.sql is z.string().regex(CUBE_MEMBER_SQL) with no refinement crossing type, so the wildcard parses under every AggregationMetricType; the pin parses the count fixture and asserts '*' against the published pattern independent of type. The pin does not cross '*' with a non-count type, and need not: the shape admits it by construction. "every admitted value other than '*' (which reads no field value) resolves to a field the gate can judge": fieldsOfColumnSql on main answers one field for a BARE_IDENTIFIER, the hop chain plus column for an IDENTIFIER_PATH, and an empty list for everything else, * named in its own comment; the where-gate's source === '*' arm treats it as no field too. The identifier half of CUBE_MEMBER_SQL is byte-equal to that pair, so every non-wildcard admitted value is attributed. This was the declared extra correction, and it fixes a sentence that was false for '*' in the prior wording — in scope of "corrected to what the schema admits". "the count-only boundary is #21000's" — see the pointer finding under ③; the schema half of the sentence is right.

dimensions.sql note — true now, on main when this lands. "'*', which the schema admits on a dimension too": DimensionSchema.sql uses the same CUBE_MEMBER_SQL; the pin parses '*' on a dimension (its comment: in the accept set for both members) and pins the two members' JSON-Schema pattern equal. "the accept set ruling D's execution parameters name for both members": the ruling's shape-after sentence names an identifier and '*' for measure and dimension sql alike. "it never made a working query": a reading of main, not a door measurement — NativeSQLStrategy#resolveDimensionSql hands * to qualifyAndRegisterJoin, which returns a non-IDENTIFIER_PATH verbatim, so the dimension column is a bare * in SELECT and GROUP BY; ObjectQLStrategy#resolveFieldName returns * as the group-by field, and no object can declare a field so spelled (^[a-z_][a-z0-9_]*$). For a non-count measure the same holds: AGGREGATE_SQL wraps SUM(*) / AVG(*) / MIN(*) / MAX(*) / COUNT(DISTINCT *), and the ObjectQL path forwards * as the aggregate field. NOT MEASURED through a door, and the note does not claim it was. "a SQL expression — a CASE bucket included — is refused at parse": pinned (invalid_format at dimensions.bucket.sql for a CASE, one issue per member).

Schema comment — true on main, and narrower than before, as the adoption allowed. "ObjectQLStrategy#resolveMeasureAggregation refuses only the number / string / boolean partition (EXPRESSION_METRIC_TYPES)": the resolver throws invalidMemberError if and only if EXPRESSION_METRIC_TYPES.has(direct.type), and that set is new Set(['number', 'string', 'boolean']) in native-sql-strategy.ts, imported by the ObjectQL strategy — one set, two strategies. "forwards an expression under an aggregate type as a field name, which fails downstream": the fall-through returns { field: direct.sql.replace(/^\$/, ''), method: type }, so an expression string becomes the engine aggregate's field; the strategy's own comment records the downstream outcome (the SQL driver's INVALID_QUERY / 400, the in-memory evaluator's null). The "two strategies never agreed" sentence that follows still holds. Internal code comment, AI-facing only in the tree; not projected.

Residual over-broad text, unchanged by the delta, the same reading the prior record made — not blocking. CUBE_MEMBER_SQL_RETIRED ("one ran it verbatim, the other refused it"), the changeset, the D3 entry's reason and the step-18 fragment still say the ObjectQL path "refused it". True for the number / string / boolean partition, which is what the changeset's FROM example (type: 'number') and the showcase's retired member used; for an aggregate-typed expression the ObjectQL path fails downstream rather than at the resolver. "never agreed" holds either way, the FROM → TO these texts exist for is unaffected, and the adoption scoped the cut to the ledger plus the optional comment. Noted, not re-raised.

Every other '*' sentence in the PR is consistent with the corrected notes — right. "or '*' for a count" (the measure .describe(), the measure prescription, the changeset, the D3 entry, the fragment, the regenerated analytics.mdx rows, the README) names the wildcard's use, not the accept set's boundary; none says '*' is refused on a dimension or count-only. The dimension TSDoc says "'*' is admitted with the measure's accept set". The PR body's "Boundary, left as ruled" bullet and both ledger notes now say the same thing.

Prior judgments the delta cannot reach — re-read, unchanged. Accept set (right: identifier half byte-equal to IDENTIFIER_PATH and the gate's BARE_IDENTIFIER / IDENTIFIER_PATH pair; no expression shape passes; quoted and $-prefixed spellings are not legitimate identifier forms). Refusal and prescriptions (right: invalid_format at measures.METRIC.sql / dimensions.DIMENSION.sql; the dataset form they name — a measure-scoped filter, derived: { op, of }, ratio as a 0–1 fraction annotated percentScale: 'fraction' — works on main today). Doors (CubeSchema, the analytics_cube write door, defineStack 422, defineCube; all pinned). Showcase migration (right: done_rate leaves the cube, done_count plus a ratio done_rate join the dataset in the paid_rate shape, same percentage at display, raw number divided by 100 and disclosed; gap-fill.test.ts re-pinned; cube-authored-format-granularity.test.ts builds its fixture unparsed and adds the parse-refusal assertion). ADR-0087 (right: D3 entry cube-member-sql-expression-retired in the generated semantic:18 region in sorted slot, fragment order: 53, registry regenerated after c6b3a01d5d, no D2, no RETIRED_KEYS_BY_MAJOR row, ratchets byte-identical). Liveness evidence anchors (#resolveMeasureSql, #resolveDimensionSql, #resolveFieldName, #fieldsOfColumnSql, #registerAll, #CAPABILITY_PROVIDERS) resolve on the current origin/main. Translations block, README paragraph, retired filters guidances, metric-filters-removed summary — each as the prior record read them.

PR body at this head — every sentence re-read; the bytes are the ones the prior record read (12555, unchanged). "What changes", "The fork clause", "The measure-level filter", "Ablations", "Acceptance notes": true against the tree. "Liveness … stay live, re-verified 2026-09-30, with the narrowing recorded": true, and truer than before. "Tests (final head fa979c57ed unless stated)": a scoped header; the two later commits (the translations block, this cut) are reported on the card (5922922632, 5923452954), not in the body, so a reader of the body alone does not learn the ledger notes were corrected. Not false; noted for the seat's merge-time edit if it wants the body to name the cut. #20965 remains open for it: true (open, pm:blocked, p2; its triage corrected "unreachable" to "fail closed on a stored expression member", which agrees with the ledger notes' "remain for a cube that reaches the service without meeting the parse" and the body's "stored rows NOT MEASURED here"). Closes #20943 and Clause-②: yes (narrowing) present.

Check-runs on ac70996345 (35 fetched, 35 distinct names, deduped by name keeping the newest started_at; none running): 33 success, 2 skipped — Console Pin Gate (no pin move) and Packed-tarball smoke (opt-in). Green include Spec property liveness (the ledger parses and its anchors resolve), TypeScript Type Check (the generated artifacts, check:migration-registry, check:adr-0087-registration), Lint & Repo Gates, Check Changeset, Type Check · consumer gates (i18n coverage at baseline), Test Core 6 of 6, Dogfood Regression Gate 3 of 3, Temporal Conformance, Build Core, Build Docs. Combined status success (Vercel).

② Semver level

Unchanged by the delta, and still right. .changeset/20943-cube-member-sql-column-reference.md: @objectstack/spec: minor with the BREAKING banner, Clause-②: yes (narrowing), the FROM → TO migration, the one-line fix and the adr-0087 registered marker naming cube-member-sql-expression-retired — an accept-set narrowing on a published schema with no export, key or def change, shipped minor under the launch-window convention (check-changeset-no-major green on this head). @objectstack/service-analytics: patch is earned by the README sentence this PR makes false; no runtime byte changes. The delta adds a code comment and two ledger notes — no published byte, no line owed. example-showcase is private. Clause-②: yes (narrowing) — the PR body and the changeset agree.

③ Boundary flags

Implemented-by: claude/issue-20943-cube-sql-identifiers
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-10-01T02:33Z · rendered by the seat's at-tier review subagent on this head. The seat read its served tier family from the subagent transcript before posting. ⚠️ The disclosure discipline holds.


Generated by Claude Code

@os-justin
os-justin marked this pull request as ready for review October 1, 2026 02:34
@os-justin
os-justin added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 5d5e679 Oct 1, 2026
40 checks passed
@os-justin
os-justin deleted the claude/issue-20943-cube-sql-identifiers branch October 1, 2026 04:12
This was referenced Oct 1, 2026
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… answers measures declared number as numbers (objectstack-ai#20889) (objectstack-ai#21040)

Fixes objectstack-ai#20889
Clause-②: yes (widening)

## What changed

On PostgreSQL, the analytics native-SQL path answered every measure its
response declares `number` as a string: `count: "2"`, `sum:
"500.000000000000000000000000000000"`. SQLite answered numbers.
`driver-sql`'s own `aggregate()` has presented these answers since
objectstack-ai#20335, but through a private table and presenter that
`service-analytics` could not reach. The seat's route ruling
(`5922780640`) moves that one rule into `@objectstack/core`, and both
faces now call it.

- **`@objectstack/core`** (new `src/utils/aggregate-answer.ts`, one
export line in `index.ts` beside `compensatedSum`):
- `AGGREGATE_ANSWER_KIND`, with its precision-policy docblock, moved
from `sql-driver.ts`. The text is byte-identical apart from the `export`
keyword; the proof is below.
- `presentAsNumber(value)`, the body of `presentReadValue`'s `'number'`
arm as a named function. A string `Number()` reads as a number becomes
that number. Any other value is returned as given.
- **`@objectstack/driver-sql`**, `sql-driver.ts`, the two ruled regions
only:
- the `AGGREGATE_ANSWER_KIND` definition becomes an import, and a
pointer comment marks where it was;
- `presentReadValue`'s `'number'` arm returns `presentAsNumber(value)`.

The import is a separate statement beside the `AggregationFunction`
import (inserted after base line 23; line 27 at HEAD). It does NOT join
the `@objectstack/core` import block at base lines 94-100, which PR
objectstack-ai#20988 rewrites. That hunk starts at base line 91, 67 lines below the
insertion. No behaviour change: the full driver-sql suite is green on
SQLite and PostgreSQL (below).
- **`@objectstack/service-analytics`**, `NativeSQLStrategy.execute`
only. After the raw statement runs, each measure column is presented by
the measure's DECLARED aggregate function (`lookupMember(cube, m,
'measure').type`), never by whether a value looks numeric:
- `count`, `count_distinct`, `sum` and `avg` (the table's `'number'`
kind) always take `presentAsNumber`;
- `min` / `max` (the `'column'` kind) take it only when
`declaredFieldType(object, column)` is in `NUMERIC_VALUE_TYPES`. That is
the rule `driver-sql`'s `readPresentationKind` applies to a declared
numeric column. A host that cannot answer, or a relationship-path
column, leaves the value as given;
- expression metric types (`number` / `string` / `boolean`) are left as
they are (ruling Q3).

One point covers both doors: the cube read (`POST
/api/v1/analytics/query`) and the dataset door (`POST
/api/v1/analytics/dataset/query`, through `DatasetExecutor`).
- **`.changeset/20889-analytics-native-measure-number.md`**:
`@objectstack/core` minor, `@objectstack/driver-sql` and
`@objectstack/service-analytics` patch, `Clause-②: yes (widening)` for
the two new root exports.

## Per-measure answers, before and after

Fixture: `text` category; `rating` stars (an integer column); `number`
amount and frac (the exact-decimal column); `currency` price. Group `a`
holds 2 rows.

- **Before, PostgreSQL:** phase 0's reading at base `7fa67dada3`,
through `AnalyticsService.query` (what the route relays verbatim). The
red pins at `719b98795e` read the same strings.
- **After:** read at `e827c237d1` through the real `dispatcher-plugin`
mount of `POST /api/v1/analytics/query`, by a scratch probe that was
deleted after the run.

| function | column (declared type) | SQLite before | SQLite after |
PostgreSQL 16.13 before | PostgreSQL 16.13 after |
|:--|:--|:--|:--|:--|:--|
| `count` | `*` | `2` | `2` | `"2"` | `2` |
| `count_distinct` | note (`text`) | `2` | `2` | `"2"` | `2` |
| `sum` | stars (`rating`, integer) | `7` | `7` | `"7"` | `7` |
| `avg` | stars (`rating`, integer) | `3.5` | `3.5` |
`"3.5000000000000000"` | `3.5` |
| `sum` | amount (`number`, decimal) | `500` | `500` |
`"500.000000000000000000000000000000"` | `500` |
| `avg` | amount (`number`, decimal) | `250` | `250` |
`"250.000000000000000000000000000000"` | `250` |
| `min` | amount (`number`) | `100` | `100` |
`"100.000000000000000000000000000000"` | `100` |
| `max` | price (`currency`) | `20.5` | `20.5` |
`"20.500000000000000000000000000000"` | `20.5` |
| `max` | note (`text`), the control | not read at base | `"y"` | not
read at base | `"y"` (left as the client gave it) |
| `sum` | amount over `9007199254740993` | `9007199254740992` |
`9007199254740992` | `"9007199254740993.000000000000000000000000000000"`
| `9007199254740992` |
| dataset `row_count` (`count`) | `*` | `2` | `2` | `"2"` | `2` |
| dataset `filtered_count`, a group the statement answered | `*` | `1` |
`1` | `"1"` | `1` |
| expression measure, type `number` | `SUM(amount) / 2` | `250` | `250`
| `"250.000000000000000000000000000000"` | unchanged, left as is (ruling
Q3) |

`fields[]` declared `number` for every measure on both dialects, before
and after. On the ObjectQL face of the same route, every answer was
already a number on both dialects, and it is unchanged.

## Move proof

`move-proof.py` (scratch) reads the block from BASE `2f2fa11d75`'s
`sql-driver.ts` and from HEAD's `aggregate-answer.ts`:

```text
table+docblock: base lines 57, core lines 57; differing lines 1: ["export const AGGREGATE_ANSWER_KIND: Readonly(...) = {"]   (type arguments elided in this copy only)
  sha1 base  e899be861c6e5e13cab24558923e41b0ca7b740c
  sha1 core (export keyword removed) e899be861c6e5e13cab24558923e41b0ca7b740c
presenter: base arm body lines 8, core function body lines 8; identical after de-indent: True
MOVE PROOF: PASS
```

The table and its docblock are byte-identical apart from `export`. The
presenter's eight body lines, comment included, are identical once
de-indented. In core, a new file header says why the rule lives there.
The moved docblock still speaks of `SqlDriver.aggregate` and
`formatOutput` in the driver's voice, and the header says so.

## Pins (red first), the ablation, and the local PostgreSQL run

- **Red, at `719b98795e`** (pins only, no fix), on a private PostgreSQL
16.13 (`initdb`, 127.0.0.1, trust):
- `@objectstack/core` `aggregate-answer.test.ts`: 1 file failed, `Cannot
find module './aggregate-answer'`.
- `service-analytics` `native-sql-measure-number-presentation.test.ts`:
5 failed, all PostgreSQL; 5 passed, all SQLite. First failure: `a
row_count is a number, never "2": expected 'string' to be 'number'`.
- `rest`, the new `analytics-dataset-measure-number-door.test.ts` and
the tightened `analytics-dataset-json-dimension-door.test.ts`: 2 failed,
both PostgreSQL; 6 passed. Failures: `a row_count is a number, never
"2"` and `expected [ [ 'x', '2' ], [ 'y', '1' ] ] to deeply equal [ [
'x', 2 ], [ 'y', 1 ] ]`.
- **Green, at `e827c237d1`:** core 5/5; service-analytics 10/10 (SQLite
and PostgreSQL); rest 8/8 (SQLite and PostgreSQL).
- **Ablation** (the strategy stops calling the presenter). Predicted
before running: exactly the PostgreSQL cells go red, service-analytics 5
and rest 2; every SQLite test and the core pin stay green.
- The mutation went through `scripts/ablation-replace.mjs` (WRAP mode,
with its restore trap) on the absolute path. The guard `if
(numberMeasures.length > 0 && Array.isArray(rows))` gained `&&
String(numberMeasures) === 'ABLATION-20889'`, so it never holds. Anchor
count went 1 to 0, blob `3bce7bb073` to `8f36f62dfe`.
- `service-analytics` was rebuilt, and `ablation-dist-preflight.mjs`
found the marker in 2 built files.
- Observed: service-analytics 5 failed (all PostgreSQL), 5 passed; rest
2 failed (both PostgreSQL), 6 passed; core 5/5. That is exactly as
predicted.
- Restore: the blob after restore equals HEAD (`3bce7bb073`), `git diff
HEAD` is 0 bytes, and porcelain is 0 lines. Then a rebuild,
`ablation-dist-preflight.mjs --absent` (marker absent from all 6 built
files, tree clean), and a re-run: all green.
- **`driver-sql`'s full suite at `e827c237d1`**, SQLite and live
PostgreSQL, `TZ=America/New_York`, with the server at `Asia/Shanghai` as
CI runs it: 210 files passed, 3 skipped; 4061 tests passed, 95 skipped.
`sql-driver-20335-aggregate-numeric-presentation.test.ts` 15 tests (1
skipped, the MySQL cell), and
`sql-driver-20387-aggregate-double-accumulation.test.ts` 11 (1 skipped).
A first run on a UTC server failed only the four suites whose
precondition asserts a non-UTC server.
- **CI:** no CI job provisions `OS_TEST_POSTGRES_URL` for
`service-analytics` or `rest`, so their PostgreSQL cells are named skips
there (`skipped: set OS_TEST_POSTGRES_URL to run this cell`). The core
pin (string to number) and every SQLite cell run in CI. The private
cluster was stopped and its data directory removed after the runs.

## Gates

All at HEAD `e827c237d1`, as ONE sequential script under the shared
verify lock, each exit code captured before any pipe.

- **Derived families:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` names 67. `--ran` reconciles: `67
derived famil(ies) accounted for — 65 run, 2 NOT-MEASURED`, 0 unrun. All
65 exit 0.
- **NOT MEASURED: `check:dual-build-cjs-loads` and
`check:type-check-debt`.** Reason: both exit 3, `PREREQUISITE NOT MET`.
They read the built output of every package, and this run built only the
dependency closure it tests. CI's `lint.yml` builds the full closure
before them.
- **The four roster families the derivation marks ⛔ (roster in a
directory this diff touches):** `check-changeset-fixed.mjs`,
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`. All exit 0.
- **Tests and typechecks:**
- `@objectstack/core` test: 62 files, 1803 tests passed. Typecheck exit
0, and its `check:test-typecheck` covers the new test.
- `@objectstack/driver-sql`: typecheck exit 0; the full suite as above.
- `@objectstack/service-analytics` full suite with
`OS_TEST_POSTGRES_URL` set: 151 files, 3468 tests passed. Typecheck exit
0; `tsc --listFiles` includes the new pin.
- `@objectstack/rest`: the two pin files, SQLite and PostgreSQL, 8/8.
Typecheck exit 0, with `check:test-typecheck` over `tsconfig.test.json`.
- **ESLint, a declared narrowing** (the repo-wide `pnpm lint` is CI's):
- Touched files: `eslint --no-inline-config --format json` over the 8
touched `.ts` files. From the JSON: 8 files, 0 errors, 0 warnings, 0
ignored.
- Population: `eslint.config.mjs`'s `files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`, which
contains all 8.
- Invariance: the config enables no type-aware linting
(`eslint.config.mjs:326-328`, no `parserOptions.project`), and this diff
does not touch the config, so no untouched file's verdict can move.

## Acceptance notes

- **MySQL: NOT MEASURED** (no MySQL server here). The presenter is not
gated by dialect, so a `DECIMAL` string would be presented the same way.
Separately, a read-only inference, also unmeasured: `plugin.ts:500`
returns `engine.execute`'s result as the rows whenever it is an array,
and `knex.raw` on mysql2 resolves to a `[rows, fields]` pair, so on
MySQL the native path may read that pair as its rows.
- **`formatOutput`'s inline copy of the presenter** (`sql-driver.ts`,
the numeric pass at about `:20303`) is not folded onto `presentAsNumber`
here, per the ruling. It is the same rule written a second time, against
`presentReadValue`'s "never a re-derivation" docblock. For
`domain:engine`.
- **Prose that now points to the old home:**
`sql-driver-20335-aggregate-numeric-presentation.test.ts:31` and
`sql-driver-20387-aggregate-double-accumulation.test.ts:7` say
`AGGREGATE_ANSWER_KIND` lives in `sql-driver.ts`. These are driver-sql
test docblocks outside this card's two regions. For `domain:engine`, or
whoever next touches those files.
- **Expression measures** (`type: 'number' | 'string' | 'boolean'`) are
untouched: `fields[]` still declares `number` while PostgreSQL answers
`"250.000…"`, text or a JS boolean. objectstack-ai#20943 (PR objectstack-ai#20998) retires SQL
expressions in cube member `sql`.
- **`min` / `max` over a relationship-path column** (`account.revenue`)
stay as the client gave them. `declaredFieldType` looks a column up on
the base object only, the same "cannot answer, do not block" tier
`measureResultType` takes.
- **Not this card:** the SUM / AVG accumulation divergence on the native
statement (`0.1 + 0.2` answers `0.3` on PostgreSQL native, against
`0.30000000000000004` elsewhere). It was measured once through the
route, as the ruling asked, and is reported to the seat. No pin
enshrines either side of it.
- The census of `analytics.query(` call sites
(`envelope-caller-census.test.ts`) is unchanged: the new pins drive the
route or call the service under another name, so no ledger row or
declared input moved.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…hboards skill and the cube checklist item (objectstack-ai#21071)

Part of objectstack-ai#20943

Clause-②: no

Ruling D's governed half (Tier H, docs only). The code half landed as
objectstack-ai#20998 (`5d5e679873`). Five files, no package source:

- `docs/adr/0021-analytics-dataset-semantic-layer.md`: a dated note at
the end of D1 naming `MetricSchema` / `DimensionSchema` `sql` in
`packages/spec/src/data/analytics.zod.ts`.
- `skills/objectstack-ui/rules/dashboards.md`: the iron rule no longer
escalates to a hand-authored Cube with raw SQL, or to a `formula` field
(virtual, no stored column); the Level A table drops "any custom-SQL
metric". File: 468 lines and 6211 tokens before and after. Package:
every `skills/**/SKILL.md` together is 4395 lines before and after (none
is touched).
- `skills/objectstack-ui/evals/analytics-inline-vs-dataset.json` (eval
2) and `skills/objectstack-ui/evals/README.md`: the same skill's eval
fixture and its index no longer name a Cube as an escalation. Eval 2's
`must_contain` drops `"Cube"`, which a correct answer need no longer
say. Tokens: the eval file goes from 1102 to 1097 (ceiling 1102), and
the README from 287 to 286 (ceiling 289). Lines: 57 and 31, unchanged.
- `docs/qa/platform-checklist/areas/dashboards.json`:
`dashboards.cube-query` revision 3 drops the showcase cube's
`done_rate`, which now lives on its dataset.

No changeset: none of the five paths ships in a package's `files[]`.

## 维护者速读(草稿)

- **改了什么:** 只改五个文档文件,不改代码。
- ADR-0021 在 D1 末尾加一条带日期的说明:分析 cube 的度量和维度,`sql`
只能写列引用;写表达式,解析时就会被拒。这是裁决 D,代码已随 objectstack-ai#20998 落地。
- 看板技能不再教“dataset 表达不了,就去手写带原始 SQL 的
Cube”,也不再把公式字段当出路。改为:存成对象上的字段(汇总字段,或存好计算值、分桶的字段),或者写应用代码。
  - 同一技能包里的评测文件和它的说明也一并改了:评测的标准答案不再把 Cube 当出路。
  - QA 清单的 cube 条目删掉 `done_rate`,它已经搬到 dataset 上了。
- **为什么改:** 代码那一半落地后,这几处文字都不对了。看板技能是 AI 写元数据时读的教材:按旧教法写出的 cube,解析时就会被拒。
- **风险与代价(含回滚):** 只改文字,不发版。看板技能规则文件的行数和 token
数改前改后一样,两个评测文件略缩短。合并后是一个提交,回滚就是 revert 它。
- **席位意见:** 建议合并。终稿见 PR 上的维护者速读评论。
- **你要做的:** 看一眼 ADR 那条说明和技能里那一段的措辞,同意就批准。

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…a column reference (objectstack-ai#21220) (objectstack-ai#21240)

Fixes objectstack-ai#21220
Clause-②: yes

Dispatched by the PM claim `5938507454` (PM loop round 1, `domain:spec`
seat 1), on the triage direction `5938409101`. An ADR-0021 dataset's
`dimensions[].field` and `measures[].field` now take the
column-reference accept set the cube members they compile to already
hold since objectstack-ai#20943 (PR objectstack-ai#20998), from ONE shared declaration. A non-column
value is refused at parse, at `dimensions.N.field` / `measures.N.field`,
with a prescription naming the ADR-0021 form. The runtime door from PR
objectstack-ai#21190 is untouched and stays as defence in depth. The changeset carries
the `(narrowing)` arm, the BREAKING banner at `minor`, and the ADR-0087
marker `registered dataset-member-field-expression-refused,
dataset-count-measure-empty-field-removed`.

## Patch round 2 — contract review `5940617829`, item ①.6

The review found one lossless sub-shape that the first round sent to D3
only: a dataset measure `{ aggregate: 'count', field: '' }`. It parses
on the base, and the objectstack-ai#21190 door skips it (its `!== ''` guard). On
SQLite's native path it compiled to `COUNT()` and answered 200. Its
producer is Studio's dataset inspector. This round:

- **New D2 conversion `dataset-count-measure-empty-field-removed`** in
`MAJOR_18_CONVERSIONS` (`order: 54`, inserted at its identifier's sort
position, defined directly above `elementFilterRemoved`). `main` landed
`form-field-public-picker-removed` at 53, so this entry takes the next
free number.
- It drops `field` from a `count` measure whose `field` is exactly `''`.
Without the key, `compileDataset` emits `sql: m.field ?? '*'`, which is
`COUNT(*)`: the row count.
- Its mechanics are measured from the precedent
`time-default-utc-suffix-dropped`, not recalled: `toMajor: 18`,
`retiredFromLoadPath: true` (ADR-0087's ratified pre-GA policy for a
lossless repair), and `retiredAfter: '17.5.0'` (the spec's current
version, the same value the precedent carries).
- It uses the shared `stripKeys` helper and emits one notice per removed
key.
- Its fixture is disjoint. The controls are left as stored: a dimension
`''`, a `sum` over `''`, a count over `'*'`, a count with no `field`,
and a count over a column.
- **Scope: only `count` + `''`.** A non-count measure with `''`, a
dimension with `''` and every expression have no working row or no
mechanical rewrite, so they stay D3-only. A padded value has no known
producer and is out of scope.
- **The D3 entry links the conversion** with `conversionIds:
['dataset-count-measure-empty-field-removed']`, the way
`18.time-default-zone-refused.ts` links its conversion. Its `reason` now
says what is true: the door refuses an expression, and it never judged
`''`. It also says that D2 carries the `count` + `''` repair and D3 the
rest. `acceptanceCriteria`, the `STEP18_RATIONALE` fragment, the
changeset, the two ledger notes and the `dataset.zod.ts` comment are
corrected to match. `registry.ts` was regenerated by
`gen:migration-registry`.
- **Pins.** The new
`src/conversions/dataset-count-measure-empty-field-removed.test.ts`
covers four things on a STORED row, through
`applyConversionsToStoredItem('dataset', row)`:
- The key is dropped, with one notice per measure, and the row then
parses.
  - Every control is the same reference.
  - The replay is idempotent.
- The conversion is registered under major 18, retired from the load
path, and linked from the D3 entry.

The table-wide fixture replay in `conversions.test.ts` covers before →
after.
- **What a NEW save does.** The write path parses with the current
schema and replays no conversion, so a new Studio save of `field: ''` is
still refused at save with the prescription to omit the key. The
producer-side change stays objectui's. A row already stored that way is
repaired on load at every stored-row seam.

## What changes

**`@objectstack/spec`**

- **One pattern.** The new module
`packages/spec/src/data/analytics-column-reference.ts` declares the
column path once (a bare identifier, then zero or more `.identifier`
hops). It sits outside the `data` barrel, like
`ui/analytics-carrier-filter.ts`, so it is not published API. It exports
two anchored forms built from that one source string:
  - `ANALYTICS_COLUMN_REFERENCE`: the path, or `'*'`.
  - `ANALYTICS_COLUMN_PATH`: the same path without the `'*'` arm.
- **Cube layer unchanged.** In `data/analytics.zod.ts`,
`CUBE_MEMBER_SQL` is now that same `RegExp` object: `const
CUBE_MEMBER_SQL = ANALYTICS_COLUMN_REFERENCE`. The declaration stays in
this file on purpose. ADR-0021's 2026-10-01 note links to
`analytics.zod.ts#CUBE_MEMBER_SQL`, and ADRs are a governed surface this
PR does not edit. The cube members' JSON-Schema `pattern` is
byte-identical; the new pin asserts it.
- **Dataset layer.** In `ui/dataset.zod.ts`:
- `DatasetMeasureSchema.field` uses
`.regex(ANALYTICS_COLUMN_REFERENCE)`. Admitted: a column, a relationship
path, or `'*'`. A count may still omit `field`.
- `DatasetDimensionSchema.field` uses `.regex(ANALYTICS_COLUMN_PATH)`,
so a dimension also refuses `'*'` (see measurement 3).
- Both are `.regex()`, not refinements, so the published JSON Schema
carries each as a `pattern`. `dropped-refinements.baseline.json` is
untouched.
- The refusal code is `invalid_format`. Each prescription opens with the
contract sentence and names ADR-0021.
- The measure prescription names the measure `filter` form and `derived:
{ op, of: [...] }`, with the 0–1 ratio scale.
- The dimension prescription says a CASE bucket becomes a field of the
object.
- The two `describe()` texts now say "never a SQL expression".
`content/docs/references/ui/dataset.mdx` is regenerated from them.
- **ADR-0087.**
- New D3 entry
`migrations/entries/semantic/18.dataset-member-field-expression-refused.ts`.
`registry.ts` was regenerated by `gen:migration-registry` and never
hand-edited inside the markers.
- One hand-written `STEP18_RATIONALE` fragment, inserted at the id's
sort position with `order: 58`. Round 1 used 57. After the round-2 base
merge it takes 58, because 57 is allocated to the in-flight PR objectstack-ai#21244's
fragment. Neither list requires unique orders:
`step18-rationale-merge.test.ts` models two fragments sharing one
`order` and only asserts a positive integer, and `main` already holds
two 56s. So whichever of the two PRs lands first, neither re-orders.
- One D2 conversion, `dataset-count-measure-empty-field-removed`, for
the one lossless sub-shape (a `count` measure's `field: ''`; see Patch
round 2). The D3 entry carries the rest: an expression has no mechanical
rewrite into a column.
  - No `RETIRED_KEYS_BY_MAJOR` row: no key left the shape.
- **Liveness.** The `dataset` ledger rows `dimensions.field` and
`measures.field` stay `live`. Each is re-verified on 2026-10-01, with
the narrowing recorded in its `note`.
- **Guide.** `content/docs/data-modeling/analytics.mdx` gains one "Key
rules" bullet saying that `field` is a column reference.

**Ratchets, as expected for a value narrowing.** The `api-surface`,
`authorable-surface`, `json-schema.manifest`, `export-origins` and
`declaration-map` artifacts are byte-identical. `spec-changes.json` and
the upgrade guide stay at protocol 17, so major-18 entries do not
project yet, and both checks are green.

## The PM's mechanism assumptions, measured

1. **Confirmed.** `dataset.zod.ts:125` (dimension, required) and `:189`
(measure, optional) were bare `z.string()` at the base `d6d6e872`.
`CUBE_MEMBER_SQL` was a module-private `const` at
`analytics.zod.ts:240`.
2. **Exporting `CUBE_MEMBER_SQL` would move the public surface.**
`packages/spec/src/data/index.ts` re-exports the whole module with
`export * from './analytics.zod'`, so an exported `CUBE_MEMBER_SQL`
becomes a new `@objectstack/spec/data` export and needs
`gen:api-surface`. I took the non-public module instead.
`check:api-surface`, `check:export-origins` and `check:declaration-map`
are green with zero changes to their artifacts.
3. **`'*'` on a dimension: measured, and refused.** Readings come from
`POST /api/v1/analytics/dataset/query` with today's spec, through the
real REST route, a real `AnalyticsService` and a real better-sqlite3
`SqlDriver`, using a temporary probe test that was deleted afterwards
(the tree is clean). The ObjectQL-strategy column bridges
`executeAggregate` straight to `SqlDriver.aggregate`, not through the
ObjectQL engine.

   | dataset member `field` | native-SQL strategy | ObjectQL strategy |
   |---|---|---|
| dimension `'*'` | 500 `DATABASE_ERROR` (`SELECT * AS ... GROUP BY *`)
| 500 `DATABASE_ERROR` (`groupBy: ['*']`) |
   | dimension `''` | 500 | 500 |
| count measure `''` | 200 (SQLite accepts the `COUNT()` it compiled to)
| 500 |
   | count measure `'*'` (control) | 200 | 200 |
   | sum measure `'*'` | 500 (`SUM(*)`) | 500 |
   | padded `' amount'` (sum) | 200 | 200 |
| padded dimension `' industry'` | 200 | 200, **dimension column
silently missing from the rows** |
| expression `amount * 2` | 403 `PERMISSION_DENIED` (PR objectstack-ai#21190's door) |
403 |

A `'*'` dimension is never answered: it compiles to grouping by every
column, which is not an axis. So the dataset dimension takes the same
path pattern without the `'*'` arm. That is one pattern source with one
stated restriction, not a second pattern; the pin proves the dimension's
published `pattern` equals the cube's with only the `\*|` arm removed.

⚠ **Flagged, not silently chosen.** The triage line reads "exactly the
`CUBE_MEMBER_SQL` accept set" for both keys. This PR narrows the
dimension one step further, as the dispatch's mechanism item 3 invited
and the card's own pin wording ("`*` (on a measure)") suggests. The cube
`DimensionSchema.sql` still admits `'*'`, per ruling D's execution
parameters, and is untouched here.
4. **Census, repo-wide, with a lit control.** A scan of every `field:`
value in tracked files that mention a dataset and
`dimensions`/`measures`, including `packages/**` tests,
`content/docs/**` and `skills/**`.
- **Lit control.** Column-reference values hit in every area, and the
scanner sees them: `examples` 116, `content` 88, `skills` 15,
`platform-objects` 6, `service-analytics` 587, `spec` 423, `lint` 282,
`rest` 92.
- **Non-column dataset `field`s found:** only the fixtures that PR
objectstack-ai#21190 wrote on purpose to drive its door: `rest`
`analytics-16019-driver-declared-fault.test.ts` (`translate(...)`,
`lower(name)`) and `service-analytics`
`inline-dataset-field-admission-door.test.ts` (an expression constant
and a template). Both were re-pinned (next section).
- **Zero** in the examples, `platform-objects`, the hand-written docs
and the published skills. Every other non-column literal the scan caught
is not a dataset field (driver-sql and protocol prose, filter paths,
`$field` prose in a skill).
- **The build as judge.** The full suites of `spec`, `lint`,
`service-analytics`, `metadata-protocol` and `rest` are green on the
narrowed contract.
- Nothing in `skills/**` teaches an expression `field`, so no Tier H
follow-up is owed.
5. **D2 for one sub-shape, D3 for the rest** (corrected in round 2; the
first round said "D3 only").
- **Expressions: D3 only.** A stored dataset with an expression `field`
already answered 403 at the dataset door. On the REST route it is now
refused one step earlier, at the route's own `DatasetSchema` parse,
which the route runs on the inline and the saved branch alike. An
expression has no mechanical rewrite.
- **Correction.** This item said that no stored row worked before. That
is false by this PR's own probe table: a `count` measure with `field:
''` answered 200 on SQLite's native path, and the door never judged it.
- **The repair.** That sub-shape gets the D2 conversion
`dataset-count-measure-empty-field-removed`, which every stored-row
rehydration seam replays (`applyConversionsToStoredItem`, e.g.
`metadata-protocol`'s `convertStoredItemDetailed`). The runtime door is
still reachable for a dataset handed to `queryDataset` unparsed: the
build probe's dashboard-widget path (`metadata-protocol`
`build-probes.ts`) passes the stored row as read.

## Fixture triage (two consumer tests the narrowing turns red; both
re-pinned, not loosened)

- **`service-analytics` `inline-dataset-field-admission-door.test.ts`**
built its expression fixtures with `DatasetSchema.parse`, which now
refuses them. The fixtures are now built UNPARSED, the shape a
pre-narrowing stored row has, through `storedDatasetWith`. The controls
still parse. One new case asserts that the contract refuses both
fixtures at `dimensions.0.field` / `measures.0.field`. All 4 provider
tiers x 2 strategies of the 403 door pins are unchanged and green.
- **`rest` `analytics-16019-driver-declared-fault.test.ts`.** The route
parses every dataset first, so its inline and saved expression cases now
answer `400 VALIDATION_FAILED`, where they answered `403
PERMISSION_DENIED`.
- Both cases are re-pinned to the `400`, plus `invalid_format` at the
path inside `detail`, the driver never called, and no expression text
echoed.
- The statement-leak check now reads SQL keywords as the strategies emit
them (upper case), because the prescription itself says "Group by the
column itself" in prose. It was case-insensitive before, when the body
carried no prose.
- The docblocks state the new layering and the reverse-verification
direction (measured below).

## Tests

All runs are at head `0d5e446e` (after merging `origin/main` at
`3ddd3d0c`) unless stated otherwise. Filter direction: each package's
own suite, no consumer sweep.

- **`@objectstack/spec`**
  - `vitest run --project local`: 597 files, 17468 passed, 1 todo.
- The new `src/ui/dataset-field-column-reference.test.ts` has 11 cases.
- The cube precedent pin `cube-member-sql-column-reference.test.ts`
stays green, with its `'*'`-on-a-cube-dimension case unchanged.
- **`@objectstack/service-analytics`** `vitest run`: 162 files, 3741
passed, 45 skipped.
- **`@objectstack/lint`** `vitest run`: 119 files, 5502 passed.
- **`@objectstack/metadata-protocol`** `vitest run`: 200 files passed, 3
skipped; 2973 tests passed, 19 skipped.
- **`@objectstack/rest`** `vitest run --project local`: 257 files, 4858
passed, 316 skipped.
- **Typecheck:** `pnpm --filter PKG typecheck` exit 0 for
`@objectstack/spec` (`tsc` + `check:scripts-typecheck` +
`check:test-typecheck`), `@objectstack/service-analytics` (its
`tsconfig` includes all of `src`, so the edited `__tests__` file is in
the program) and `@objectstack/rest` (`tsc` + `check:test-typecheck`).
- **`@objectstack/spec` `--project repo`, the relevant files:**
`step18-rationale-merge`, `conversions-major18-merge`,
`liveness/evidence`, `liveness/proof-registry`,
`retired-key-migrate-sentence`, `file-description`, `root-index`,
`export-list`, `category-title`, `schema-tree-freshness`, `escape-mdx`
and `references-banner`. 12 files, 294 passed.
- **Lint, a declared narrowing.** `eslint --no-inline-config --format
json` over the 8 changed lintable files at `0d5e446e` gave 8 files, 0
errors, 0 warnings.
- **Population:** from `eslint.config.mjs`, the
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block. The other changed files
are `.md`, `.mdx` and `.json`.
  - **File count:** read from the JSON output.
- **Invariance:** the config never enables type-aware linting (no
`parserOptions.project`, no typed rules), so this diff cannot move a
verdict on an untouched file.
  - The whole-repo `pnpm lint` is CI's.

## Round 2 readings (final head `fc4e91c0`; `origin/main` `3dc33b2d`
merged through `os-regen-merge.sh`)

These readings were taken after a container restart. The restart cut a
first round-2 gate run short at `2e57fa29`. Every reading below was
re-taken at `fc4e91c0`, the pushed head.

- **Build.** `turbo run build` over the closures of spec, cli,
service-automation, metadata-protocol, rest and client-react: 59/59
tasks.
- **`@objectstack/spec`**
- `vitest run --project local`: 597 files, 17465 passed, 1 todo. The
counts moved with `main`'s merge.
- That includes the new
`src/conversions/dataset-count-measure-empty-field-removed.test.ts`, the
table-wide fixture replay in `conversions.test.ts` and
`retired-after.census.test.ts`.
- `--project repo`: the same 12 relevant files as round 1
(`step18-rationale-merge` and `conversions-major18-merge` among them),
294 passed.
- **Consumers that read the conversion table.**
- `@objectstack/cli` `meta.report-order.test.ts` (unit tier): 16 passed.
- `@objectstack/service-automation`
`decision-overlapping-edge-conditions.pin.test.ts`: 22 passed.
- `@objectstack/metadata-protocol` full suite (it hosts the stored-row
seam): 200 files passed and 3 skipped; 2973 tests passed and 19 skipped.
- **Not re-run this round.** `rest`, `service-analytics` and `lint`:
this round's diff does not reach them (spec only), and their round-1
readings stand.
- **Lint, a declared narrowing.** `eslint --no-inline-config --format
json` over the 10 changed lintable files at `fc4e91c0` gave 10 files, 0
errors, 0 warnings. The population and invariance are as in round 1.
- **Gates.** `dispatch-gates --commands` at `fc4e91c0` derived the same
115 families. All were run with exit codes recorded, and `--ran`
reconciled them as "115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN".
  - `check:generated`: 15/15 up to date.
  - `check:migration-registry`: exit 0.
- `check:adr-0087-registration`: it reads `registered
dataset-member-field-expression-refused,
dataset-count-measure-empty-field-removed`, both new here.
- `check:skill-examples` and `check:dual-build-cjs-loads` both exited 0
this time. Both had been NOT MEASURED in round 1 for want of built
packages.

## Ablations

Each ablation ran from committed state, disk-verified through
`scripts/ablation-replace.mjs`. Each restore was proven by blob hash
equal to HEAD, an empty `git diff HEAD`, and a clean status. The
predicted direction was "turns red" in all four, and that is what was
observed.

| leg | mutation | under mutation | restored |
|---|---|---|---|
| A1 | measure `field` pattern admits anything | new pin: 7 failed / 4
passed (every measure refusal, door, pattern and defineStack case red;
the dimension and accept cases green) | 11 / 11 |
| A2 | dimension takes `ANALYTICS_COLUMN_REFERENCE` (admits `'*'`) | 3
failed / 8 passed (the dimension refusal case on `'*'` and the two
pattern pins) | 11 / 11 |
| A3 | `ANALYTICS_COLUMN_PATH` admits anything, then `@objectstack/spec`
rebuilt | `ablation-dist-preflight`: marker in 18 built files. `rest`:
the 2 re-pinned cases red, `expected 403 to be 400` (the route's parse
passes the expression to the service door, the direction its docblock
predicts); 6 green. `service-analytics` door test: the contract case
red, 20 green | rebuilt; marker absent from all 230 built files; tree
clean |
| A4 (round 2, at `fc4e91c0`) | the new conversion matches nothing (its
`field !== ''` guard reads a value no row carries) | 2 failed / 239
passed: the `conversions.test.ts` fixture pin
`dataset-count-measure-empty-field-removed: before → after, emits 2
notice(s)` and the stored-row pin are red; the controls are green | blob
`75f4166c` == HEAD; `git diff HEAD` empty; status clean |

No ablation file is left in the tree.

## Gates

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`, run at `0d5e446e` with no paths, derived 115 families.

- All 115 were run, each with its exit code recorded before any pipe.
- `--ran` reconciled them: "115 derived, 114 run, 1 NOT-MEASURED, 0
UNRUN", every row carrying its recorded exit code.
- **NOT MEASURED: `check:dual-build-cjs-loads`.** Reason: it exited 3
(`PREREQUISITE NOT MET`) because 44 packages outside this diff's build
closure have no `dist/`, and only a full monorepo build supplies them.
This diff changes no package entry, export map or build config. CI runs
it on the full build.
- **`check:skill-examples`** first exited 3 for want of a built
`@objectstack/client-react`. After building that package it exited 0 at
`0d5e446e`: 259 examples type-check.
- Every other family exited 0. That includes:
- `check:generated`: all 15 artifacts up to date against a stamp-matched
dist.
- `check:adr-0087-registration`: at the first round's head it read
`registered dataset-member-field-expression-refused (new here)`.
  - `check:changeset-no-major` and `check:empty-changeset`.
- `check:liveness`, `check:migration-registry` and
`check:doc-authoring`.
  - `check:cross-package-test-inputs` and `check:nul-bytes`.

## Acceptance notes

- **File surface, declared.** The claim named `dataset.zod.ts`,
`analytics.zod.ts` "only as far as sharing needs", the ADR-0087 entry
and registry, the retirement kit, pins and one changeset. Four paths go
beyond that, each for the stated reason:
- The new non-public module `data/analytics-column-reference.ts`: the
sharing change itself, which avoids a public export.
- The two consumer test files the narrowing turned red: fixture triage,
re-pinned rather than loosened.
- One bullet in `content/docs/data-modeling/analytics.mdx`: the skill's
docs row.
- **The REST door's answer moves from 403 to 400 for an expression
`field`.** The route's existing `DatasetSchema.parse` refuses it first,
as `VALIDATION_FAILED` naming the path. The changeset says so. The
service door's 403 is unchanged.
- **Studio producer (objectui, outside this repo).**
- **What happens.** `DatasetDefaultInspector.tsx` at the pinned
`31971ff1e` seeds a new dimension row as `{ name: '', field: '', type:
'string' }` and a new measure row as `{ name: '', aggregate: 'sum',
field: '' }`. A plain count measure left with a blank Field box is saved
as `field: ''`. That parsed before. Its query answered 500 on the
ObjectQL path (the SQLite native path happened to accept `COUNT()`).
- **After this PR** a new save of that shape is refused at
`measures.N.field`, with the prescription to omit the key. A row already
stored that way is repaired on load by the D2 conversion
`dataset-count-measure-empty-field-removed`.
- **The producer half** is to omit `field` when the box is blank. It is
reported to the seat, not edited here.
- **Measured, not acted on.** Neither item is filed from here; both are
in the report.
- A `sum` (or any non-count aggregate) over `'*'` parses on a dataset
measure and answers 500 on both strategies. That is the count-only `'*'`
boundary the `analytics_cube` ledger already assigns to objectstack-ai#21000's family.
- A padded dimension `field` answered 200 with the dimension column
missing on the ObjectQL bridge. It is now refused at parse. A stored
padded row would still reach that path through the build probe; no
producer of one is known.

Authored by `session_01UtnxvdiN376GF3sgXwAw4d` (rounds 1 and 2).

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…eir src trees to the dispatch derivation (objectstack-ai#21271)

Fixes objectstack-ai#21011
Clause-②: no

## What

`check:i18n-coverage` now declares what it reads to the dispatch
derivation, so an edit under an example app derives the gate locally. CI
already ran it on the same diff.

- `scripts/check-i18n-coverage.mjs`: `const ROOT_DIR_WATCH_HINTS =
['examples/*/objectstack.config.ts', 'examples/*/src/**'];` beside
`EXAMPLES_DIR`, plus a new `--self-test` battery that holds the
declaration against the live tree (details below). What the gate judges
is unchanged.
- `scripts/pm/bare-root-worklist.mjs`: the `check-i18n-coverage.mjs
EXAMPLES_DIR examples` row is re-decided from `SPELLABLE-UNDECLARED` to
`DECLARED-NARROWER`. Its recorded spelling is re-measured as the two
hints above, and its old single-hint spelling entry is replaced.
- `scripts/pm/dispatch-gates.mjs` is **not touched**. It is frozen under
ruling 208 R6, and its self-test stayed green with the declaration in
place (see Gates).

## Why this spelling and not `examples/**`

The gate runs `os lint` on each example config. `os lint` loads the
config through bundle-require: esbuild bundles every relative import,
and `@objectstack/*` plus the driver modules stay external
(`BUNDLE_REQUIRE_EXTERNALS` in `packages/cli/src/utils/config.ts`). So
the count moves with anything in the config's relative import graph.
Measured with esbuild and those externals at `30c530e5`:

| | tracked files | of which in the 4 configs' import closures |
|---|---|---|
| closure (the real read) | 153 | 153 |
| declared: `examples/*/objectstack.config.ts` + `examples/*/src/**` |
170 | 153 (complete, 90% precise) |
| `examples/**` (bare-root spelling) | 256 | 153 (60% precise) |

Every closure file is either a config or a file under that same
example's `src/`. Here are the 17 declared files that no config imports:
- 11 `src/docs/*.md` pages. `os lint` reads them, but only through
`docs/*` rules, and the gate counts `i18n/` rules alone.
- 2 `*.test.ts` files under `app-todo/src`.
- 3 unimported files under `app-showcase/src`.
- the `src/` of `embed-objectql`, which has no config.

`examples/**` would add the test suites, e2e specs, READMEs, changelogs
and manifests. That is 86 more files, none of which a config imports.

## Pins (in the gate's own `--self-test`, new battery, 10 cases)

The battery imports the derivation's own `extractWatchHints` and
`hintCovers`. The import is dynamic and sits inside `selfTest`, which
the extractor masks. So the gate's own extracted hints gain exactly the
two declared literals, and its import follow is unchanged: before
`[cli-build-prerequisite.mjs, import-prerequisite.mjs]`, after the same.

- The derivation reads both declared hints off this source.
- Positive: the three showcase source files of PR objectstack-ai#20998 derive the
gate, and so does every config `discoverExamples()` finds (4).
- Completeness: no relative import in a config or in a non-test file
under its `src/` resolves outside that example's `src/`. On this tree
that is 177 edges and 0 escapes. A negative control proves the scan
still sees an escape.
- Precision: these do not derive through this declaration:
`examples/app-showcase/test/gap-fill.test.ts`, `examples/README.md`, and
the two `packages/` paths of PR objectstack-ai#20998. Controls show that the extractor
drops a computed spelling, and that `examples/**` would have named the
test file.

## Measured end to end with `dispatch-gates --commands`

Before is base `30c530e5`, after is head `61bd05aa`.

| change set | before | after |
|---|---|---|
| PR objectstack-ai#20998's full file list (15 paths) | 116 commands, no
`check:i18n-coverage` | 117, the only addition is `pnpm
check:i18n-coverage` |
| its 4 `examples/app-showcase/**` paths alone | 40, absent | 41,
present |
| its 2 `packages/` paths alone
(`packages/spec/src/data/analytics.zod.ts`,
`packages/services/service-analytics/README.md`) | 74, absent | 74,
byte-identical list |
| `examples/app-showcase/test/gap-fill.test.ts` alone | (not measured) |
38, absent |

## Ablations

All four were run through `scripts/ablation-replace.mjs` against the
committed head. Each mutation was proven on disk by its anchor count and
blob hash, and each restore was proven by `blob == HEAD` and an empty
`git diff HEAD`:

1. Declaration replaced by `['examples/**']`: the self-test goes red, 1
failure. The precision case names the test file and
`examples/README.md`.
2. Declaration computed from `EXAMPLES_DIR`: red, 4 failures. The
extracted hints fall back to
`["scripts/i18n-coverage-baseline.json","i18n","node_modules"]`.
3. `src/**` hint dropped: red, 2 failures (the showcase edit and the
import targets are no longer derived).
4. Same mutation as 3, run against `bare-root-worklist.mjs --self-test`:
red, 1 failure, "NOT DECLARED: examples/*/src/**". The map and the gate
cannot drift apart silently.

## Gates (all at head `61bd05aa`)

The family list came from `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` (no paths, change set from the
merge base). It names the same 30 the dispatch listed, and all 30 exited
0. `--ran` reconciliation: "30 derived famil(ies) accounted for — 30
run, 0 NOT-MEASURED".

- `node scripts/check-i18n-coverage.mjs --self-test` passed, then the
gate itself: `check-i18n-coverage: OK (13 config(s), 621 baselined
untranslated string(s), none new)`. It ran after the gate's own closure
build under the verify lock (`VERDICT command-exit 0`).
- `node scripts/pm/bare-root-worklist.mjs --self-test`: OK. There are
now 12 DECLARED-NARROWER records held set-equal to their gates' arrays;
there were 11.
- `pnpm check:pm-dispatch-gates`: `dispatch-gates self-test: 1976 cases
pass`, run detached for 825.6s. No pin moved, and the frozen file is not
in the diff.
- Not on the derived list, run anyway because it judges declarations:
`node scripts/check-declared-population-live.mjs` passed with 266 of 326
families live.
- The other 27 derived families were all exit 0: ci-filter-parity,
closing-keyword-parity (+self-test), comment-mask-corpus,
declaration-mirrors (+self-test), scripts-symbol-anchors (+self-test),
self-test-wired (+self-test), self-test-workflow-commands (+self-test),
whole-set-label-write (+self-test), agent-test-spelling, bash32-floor,
cli-command-ids, cross-package-test-inputs, driver-memory-census,
entry-guard, gitlink-declared, nul-bytes, parse-guard,
pnpm-filter-targets, ratchet-remedy-authority, refd-timer-probe and
watch-hint-literal.
- Lint, narrowed and declared as narrowed: `eslint --no-inline-config
--format json` over the 2 changed files returned 2 files, 0 errors and 0
warnings. `eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict on any
untouched file. The full `pnpm lint` is left to CI.

## Acceptance notes

- **The PACKAGES half is out of scope, on purpose.** `PACKAGES_DIR`
stays undeclared, and its worklist row keeps `SPELLABLE-UNDECLARED` (9
extract configs share one spelling with `check:i18n-bundles` and
`check:i18n-stale-fill`, deferred together). The gate's closure also
reaches every workspace package a config imports by name, so a
`packages/spec` edit can move this count too. Declaring that would be
the `packages/` root wholesale, the REFUSE-WIDE shape. The CLI half
already reaches this gate through `cli-build-prerequisite.mjs`.
- **The dispatch premise was off by one verdict.** The dispatch and
claim describe the worklist record as REFUSE-UNSPELLABLE, and so does
the gate's old header comment. On `main` the row was
`SPELLABLE-UNDECLARED` with "Deferred: no consumer has asked". This card
is that consumer, and moving the row to DECLARED-NARROWER follows the
split criterion of the 2026-08-26 ruling recorded in the worklist
header. A paragraph there records the re-decision. The stale header
comment in the gate is replaced.
- `selfTest` in `check-i18n-coverage.mjs` became `async`, because it
needs the dynamic import. The dispatch reads `(await selfTest()) !==
SELF_TEST_VERDICT`, a spelling other gates already use. Self-test
runtime went from about 0.11s to about 0.2s.
- `skip-changeset`: `scripts/**` publishes nothing from any package.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants