Skip to content

feat(spec)!: an analytics dataset dimension's and measure's field is a column reference (#21220) - #21240

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21220-dataset-field-column-reference
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21220-dataset-field-column-reference

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #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 #20943 (PR #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 #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 #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 feat(spec)!: record:line_items gets its ComponentPropsMap row, its columns are the inline grid column contract, and the showcase Tasks grid binds by name (#21142) #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 fix(service-analytics)!: refuse a caller-supplied analytics member that is not a column reference at the door #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 fields found: only the fixtures that PR fix(service-analytics)!: refuse a caller-supplied analytics member that is not a column reference at the door #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.

Authored by session_01UtnxvdiN376GF3sgXwAw4d (rounds 1 and 2).

claude added 5 commits October 1, 2026 19:26
…ference

The dataset layer's field takes the accept set its cube members hold, from
one shared declaration (data/analytics-column-reference.ts); a dimension
refuses the row wildcard. D3 entry dataset-member-field-expression-refused.

Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
…rrowed contract; regenerate the dataset reference

The route parses every dataset first, so an expression field is now answered
400 VALIDATION_FAILED at its path; the service door's 403 stays pinned on
unparsed (stored) fixtures.

Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 25 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/liveness/dataset.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/data-modeling/analytics.mdx (via DatasetMeasureSchema (symbol, a top-level const))
  • content/docs/data-modeling/queries.mdx (via deal_count (literal, a string literal in fixture))
  • content/docs/protocol/objectui/index.mdx (via deal_count (literal, a string literal in fixture))
  • content/docs/ui/dashboards.mdx (via deal_count (literal, a string literal in fixture))

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

  • content/docs/releases/v17/17-5.mdx (via retiredAfter (symbol, a field of const object datasetCountMeasureEmptyFieldRemoved), retiredFromLoadPath (symbol, a field of const object datasetCountMeasureEmptyFieldRemoved))

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
  • 1 changed file(s) yielded no anchor (packages/spec/liveness/dataset.json) — pages documenting those are invisible to this run
  • 9 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 3dc33b2d13a919db611bc077d42337df51ec626d → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 3dc33b2d13a919db611bc077d42337df51ec626d

⚠️ 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 3dc33b2d13a919db611bc077d42337df51ec626d → 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: 0d5e446ebd74306d9fab618d968085ae2da030b6
Local-runs: none

Isolated reviewer for card #21220 / PR #21240. Inputs: the card body and all four of its comments (triage 5938409101, claim 5938507454, dev report 5940231654, seat answer 5940285278), the PR body, its 12-file list, the net diff origin/main...0d5e446e, git show reads of origin/main and of the pinned objectui 31971ff1e, and the head's check-runs (last read 2026-10-01T21:08Z). Nothing built, run or re-run.

① Derived judgments

  1. DatasetDimensionSchema.field — RIGHT, with one accepted deviation. Before: z.string(), any string. After: .regex(ANALYTICS_COLUMN_PATH) = ^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$. Column (stage) and dotted relationship path (account.owner.region) parse byte-identically (the pin asserts r.data equals the input). Refused at dimensions.N.field: an expression, a quoted or $ spelling, '', a padded value, a broken path, and '*'. The '*' refusal is one step narrower than the card's "exactly the CUBE_MEMBER_SQL accept set" sentence. I judge it right: the card's own pin line reads "* (on a measure)", a '*' dimension is copied verbatim into a cube dimension's sql and compiles to GROUP BY *, which no strategy ever answered (the dev's probe table: 500 on both), and ADR-0049 / Prime Directive chore: version packages #10 keep the declaration as narrow as the enforcement. It is one pattern source with one stated restriction, not a copy: the pin proves the dimension's published pattern equals the cube's with only the \*| arm removed. The cube DimensionSchema.sql keeps '*' (ruling D) and is untouched.
  2. DatasetMeasureSchema.field — RIGHT. Before: z.string().optional(). After: .regex(ANALYTICS_COLUMN_REFERENCE).optional(), exactly the CUBE_MEMBER_SQL set: column, dotted path, '*', or absent. Refused at measures.N.field: an expression, '', a padded value, a broken path. A count may still omit field. '*' stays admitted on every aggregate (a sum over '*' parses and answers 500), which is the pre-existing count-only boundary the dev assigns to spec+service-analytics: retire the cube metric types number / string / boolean — they existed to carry a SQL expression, which #20943 retired from a member's sql #21000's family; nothing widened here.
  3. Cube layer byte-identical to origin/main — RIGHT. CUBE_MEMBER_SQL on main is the literal /^(?:\*|[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)$/ with no flags; on the head it is ANALYTICS_COLUMN_REFERENCE = new RegExp('^(?:\\*|' + COLUMN_PATH + ')$') with no flags and the same source string, so .source and .flags are identical and the published JSON-Schema pattern is byte-identical (pinned against MetricSchema). Sharing one RegExp object across three .regex() sites is safe without a g/y flag. The declaration name stays in analytics.zod.ts, so ADR-0021's analytics.zod.ts#CUBE_MEMBER_SQL link still resolves; scripts/adr-anchors/ has no analytics row to move.
  4. Public surface unmoved — RIGHT. data/analytics-column-reference.ts is imported by analytics.zod.ts and ui/dataset.zod.ts only and is re-exported by none of data/index.ts, ui/index.ts, src/index.ts (the ui/analytics-carrier-filter.ts precedent). CUBE_MEMBER_SQL stays module-private. The diff touches none of api-surface/*.json, api-surface-signatures.json, authorable-defaults/*, json-schema.manifest/*; packages/spec/json-schema/ is gitignored build output, so no in-tree regen was owed for the new pattern. The inferred type of both keys stays string. The check:api-surface / export-origins / declaration-map / check:generated conclusions belong to Lint & Repo Gates, green on this head (see ②).
  5. Refusal path, code and prescription — RIGHT. .regex() with an error callback returning the prescription yields invalid_format at dimensions.N.field / measures.N.field, pinned on DatasetSchema, on both member schemas alone, on the dataset write-door binding (getMetadataTypeSchema('dataset') === DatasetSchema) and on defineStack (STACK_SCHEMA_INVALID / 422 at datasets.0.measures.1.field). Both prescriptions open with the contract sentence, name ADR-0021 by name, and name the form: the measure text gives the structured filter and derived: { op, of: [...] } examples with the 0–1 ratio scale; the dimension text says group by the column or keep the bucket as a field. No tracker number in any runtime string (check:doc-authoring discipline). The describe() texts now say "never a SQL expression" and the generated references/ui/dataset.mdx rows match them.
  6. ADR-0087 disposition — RIGHT for the expression family, WRONG for one lossless sub-shape; this is the FAIL. D3 entry dataset-member-field-expression-refused under step 18, registry regenerated inside the markers, STEP18_RATIONALE fragment at order: 57, no RETIRED_KEYS_BY_MAJOR row (no key left the shape): right. "No D2 conversion — an expression has no mechanical rewrite into a column": true of an expression. The brief asked me to measure whether any stored row works today that a conversion would owe. One does. A measure { aggregate: 'count', field: '' } parses on origin/main (z.string().optional(); the superRefine requires-field rule exempts count), the compiler emits sql: m.field ?? '*' which is '' (dataset-compiler.ts:721), the fix(service-analytics)!: refuse a caller-supplied analytics member that is not a column reference at the door #21190 door skips it by its own m.field !== '' guard (analytics-service.ts:2087), and the dev's probe table reads 200 on the native strategy (SQLite accepts COUNT()). Its producer is named: DatasetDefaultInspector.tsx at the pinned 31971ff1e seeds every new measure with field: '' (:551, :601). After this PR the REST saved branch (getMetaItems → stripReadDecorations → DatasetSchema.parse, rest-server.ts:11443) answers such a row 400 where it answered 200, and applyConversionsToStoredItem at that seam replays MAJOR_18_CONVERSIONS, so a conversion would rescue it. The repair is mechanical and lossless: delete field when aggregate === 'count' and field === '', which compiles to COUNT(*). ADR-0087's ratified pre-GA policy exempts the load window, never the chain: "a retiredFromLoadPath conversion when lossless, and one D3 semantic entry per retirement family in every case; D2 carries the mechanical data repair only", in the same release; time-default-utc-suffix-dropped is this repo's value-level precedent for exactly that split. The D3 entry's own acceptanceCriteria states the repair in prose ("A count measure that carried field: '' omits the key and still counts rows") and hands it to the author. The PR body's "No stored row worked before" is FALSE by the dev's own table. Owed: one MAJOR_18_CONVERSIONS entry (retiredFromLoadPath: true, retiredAfter at the current spec version, a disjoint fixture, pnpm --filter @objectstack/spec gen:migration-registry), with the D3 reason, the changeset's "No D2 conversion" sentence and the PR body adjusted to say D2 carries the '' repair and D3 the rest; or a recorded seat / maintainer ruling on the card that no D2 is owed for this sub-shape, after which this head can be re-reviewed. The dimension '' and every expression have no mechanical rewrite: D3 only is right for them. A padded value (' industry') would trim mechanically too, but no producer is known; noted, not a FAIL reason.
  7. Liveness rows — RIGHT. dataset ledger rows dimensions.field and measures.field stay live at the same evidence sites, verifiedAt moved to the PR's date, the narrowing recorded in each note with the D3 id and the standing fix(service-analytics)!: refuse a caller-supplied analytics member that is not a column reference at the door #21190 door. Spec property liveness check green.
  8. Consumer tests — re-pinned, not loosened, with two disclosed narrowings. service-analytics inline-dataset-field-admission-door.test.ts: the expression fixtures are now built unparsed through storedDatasetWith (the stored-row shape the door is defence in depth for), every 403 door pin keeps its inputs and assertions, and a new case pins the contract refusing both fixtures at dimensions.0.field / measures.0.field. rest analytics-16019-driver-declared-fault.test.ts: the inline and saved expression cases move 403 → 400 and GAIN invalid_format at the path inside detail, execute never called, no expression text echoed. Two narrowings, both stated in the diff: toContain('lowered_name') is dropped (the 400 body names the path, not the member), and the statement-leak check /SELECT|GROUP BY/i became /\bSELECT\b|\bGROUP BY\b/ because the prescription's prose says "Group by the column itself"; acceptable, since both strategies emit upper-case keywords and the expression-text checks (/translate/i, /lower\(name\)/i) stay case-insensitive.
  9. Published prose, sentence by sentence. Changeset: level, banner, Clause-②: yes (narrowing), marker — TRUE; FROM → TO block with field: '' and the CASE measure both refused at their paths — TRUE; "tsc does not: the key's type is still string" — TRUE; POST /api/v1/analytics/dataset/query "parses every dataset it is handed, inline or saved, and now answers 400 … where it answered 403" — TRUE (the route parses after stripReadDecorations on both branches); defineStack 422 and the dataset write door — TRUE (pinned); "The rule is a pattern in the published JSON Schema too" — TRUE; "A column reference parses byte-identically to before" — TRUE; "no RETIRED_KEYS_BY_MAJOR row … ratchets are unchanged" — TRUE; the Studio reach paragraph — TRUE (verified at the pin); "The analytics dataset door already refused a non-column field on every query" — TRUE for an expression, FALSE for '', which the door's !== '' guard never judged (the card body carried the same sentence); "No D2 conversion — an expression has no mechanical rewrite into a column" — TRUE as written, silent on the lossless '' sub-shape (item 6). content/docs/data-modeling/analytics.mdx bullet — TRUE. Generated references/ui/dataset.mdx rows — TRUE, they are the describe() texts. Migration entry: surface and replacement — TRUE; reason — carries the same door sentence (TRUE for expressions, FALSE for '') and the same D2 sentence; acceptanceCriteria — TRUE, and it is where the mechanical repair D2 should carry is written down.

② Semver level

'@objectstack/spec': minor with the BREAKING banner, Clause-②: yes (narrowing) and exactly one marker adr-0087: registered dataset-member-field-expression-refused — the level and arm ADR-0087's amended pre-GA rule and the Post-Task Checklist prescribe for a value narrowing of an authorable key; check-changeset-no-major.mjs exists and Check Changeset is green on this head. The PR body carries Clause-②: yes and the arm lives in the changeset, which is where check:adr-0087-registration reads it — correct form. The level is right; what is incomplete is the disposition's D2 half (①.6), not the level.

Check-runs on 0d5e446e at my final read (35 runs, all concluded, none in_progress): green — Auto Label, Build Core, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate (rollup, 1/3, 2/3, 3/3), Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates (the carrier of check:api-surface, check:export-origins, check:declaration-map, check:generated, check:migration-registry, check:adr-0087-registration), No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance (live PG + MySQL), Test Core (rollup and all six shards), The card this PR closes must claim this branch, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check, filter. Skipped (path-filtered, not a pass): Console Pin Gate, Packed-tarball smoke (opt-in). No check concluded red. The FAIL above does not rest on a check: every gate is green; it rests on ①.6.

③ Boundary flags

  • Dev flag: the dimension refuses '*', one step narrower than the triage/card "exactly" sentence. ANSWERED — accepted on the grounds in ①.1. The seat may overrule; the pin that would move is dataset-field-column-reference.test.ts's pattern case.
  • Dev deviation: file surface beyond the claim (the non-public module, the two consumer tests, one analytics.mdx bullet). ANSWERED — each is the narrowing's own consequence; the module is the alternative to a public CUBE_MEMBER_SQL export that would have moved api-surface. Accepted.
  • Dev deviation: PR body Clause-②: yes without the arm. ANSWERED — the arm is read from the changeset; accepted (②).
  • Dev deviations: attribution trailer form; origin/main moved two commits after the merge and was not re-merged. ANSWERED — AGENTS.md's model-free trailer governs; the queue leg re-merges and registry.ts merges through the regen script. Neither is a contract matter.
  • Open question (sequencing against the Studio producer), seat answer A in 5940285278. Its stated facts hold: no export is removed or renamed (the only new export lines are ANALYTICS_COLUMN_REFERENCE and ANALYTICS_COLUMN_PATH in the non-barrel module); objectui at 31971ff1e imports none of CUBE_MEMBER_SQL, ANALYTICS_COLUMN_* or the new module path (grep empty), names DatasetMeasureSchema only in a test comment, and field stays string, so the pinned build compiles against nothing that moved; DatasetDefaultInspector.tsx seeds field: '' at :551 and :601. The ordering answer (land, file the objectui producer card) stands. ESCALATED — the answer's "a second spelling of absent is the consumer tolerance the framework rules out" settles the schema (authoring) half, which this PR gets right, but not the data-at-rest half: Prime Directive Add comprehensive test suite for Zod schema validation #12 and ADR-0087 put a tolerated legacy spelling in the conversion layer, declared and expiring, and the seat's answer does not rule on that. ①.6 names what is owed or the ruling that would discharge it.
  • out_of_scope_findings. (a) Studio producer: the seat says the objectui card is filed; outside my inputs, not verified. (c) sum over '*' → the count-only boundary assigned to spec+service-analytics: retire the cube metric types number / string / boolean — they existed to carry a SQL expression, which #20943 retired from a member's sql #21000's family; consistent with the ledger note the dev cites. Padded dimension ' industry': now refused at parse; a trim conversion would be mechanical but no producer is known; noted only.

Implemented-by: claude/issue-21220-dataset-field-column-reference
Reviewed-by: session_01UtnxvdiN376GF3sgXwAw4d

VERDICT: FAIL

claude added 2 commits October 1, 2026 21:11
…conversion

dataset-count-measure-empty-field-removed (retiredFromLoadPath, retiredAfter
17.5.0) drops field: '' from a count measure, which then compiles to COUNT(*);
the D3 entry links it and states that the dataset door never judged an empty
field. Changeset, ledger notes and rationale fragment corrected to match.

Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
claude added 2 commits October 1, 2026 21:49
…fter the base merge

STEP18_RATIONALE fragment 57 -> 58 (57 is allocated to the in-flight
ui-record-line-items-props-closed); MAJOR_18_CONVERSIONS entry 53 -> 54 (main
landed form-field-public-picker at 53). Neither list requires unique orders.

Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Isolated reviewer for card #21220 / PR #21240, second review (the first, 5940617829 on head 0d5e446e, was FAIL on ①.6 alone). Inputs: the card body and all six of its comments (triage 5938409101, claim 5938507454, dev report 5940231654, seat answer 5940285278, seat response 5940653144, patch-round dev report 5941831350), the PR body, its 14-file list, its two comments, the net diff origin/main...fc4e91c0 (merge-base 3dc33b2d), git show reads of origin/main, and the head's check-runs (last read 2026-10-01T22:29Z). Two verification reads outside that set, each for one claim the PR body makes: PR #21244's migrations/registry.ts patch (the order-57 allocation) and objectstack-ai/objectui#11402 (the producer carrier). Nothing built, run or re-run. The whole PR is judged on this head; the patch-round delta has its own paragraph (①.6).

① Derived judgments

  1. DatasetDimensionSchema.field — RIGHT, with the accepted deviation re-confirmed. z.string() → .regex(ANALYTICS_COLUMN_PATH) = ^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$. A column and a dotted relationship path parse byte-identically (the pin asserts r.data equals the input). Refused at dimensions.N.field: an expression, a quoted or $ spelling, '', a padded value, a broken path, and '*'. The '*' refusal is one step narrower than the card's "exactly the CUBE_MEMBER_SQL accept set"; accepted on the first review's grounds, unchanged on this head: the card's own pin line reads "* (on a measure)", a '*' dimension is copied verbatim into a cube dimension's sql and compiles to GROUP BY *, which no strategy answered, and ADR-0049 / Prime Directive chore: version packages #10 keep the declaration as narrow as the enforcement. One pattern source with one stated restriction: the pin proves the dimension's published pattern equals the cube's with only the \*| arm removed. The cube DimensionSchema.sql keeps '*' (ruling D) and is untouched.
  2. DatasetMeasureSchema.field — RIGHT. z.string().optional() → .regex(ANALYTICS_COLUMN_REFERENCE).optional(): column, dotted path, '*', or absent — exactly the CUBE_MEMBER_SQL set. Refused at measures.N.field: an expression, '', a padded value, a broken path. A count may still omit field. '*' stays admitted on every aggregate, the pre-existing count-only boundary assigned to spec+service-analytics: retire the cube metric types number / string / boolean — they existed to carry a SQL expression, which #20943 retired from a member's sql #21000's family; nothing widened.
  3. Cube layer byte-identical — RIGHT. On origin/main CUBE_MEMBER_SQL is the literal /^(?:\*|[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)$/; on the head it is const CUBE_MEMBER_SQL = ANALYTICS_COLUMN_REFERENCE, a new RegExp over the same source string with no flags, so .source, .flags and the published JSON-Schema pattern are identical (pinned against MetricSchema). The declaration name stays in analytics.zod.ts, so ADR-0021's link resolves; no ADR file is in the diff.
  4. Public surface unmoved — RIGHT. On the head data/analytics-column-reference.ts is named by none of data/index.ts, ui/index.ts, src/index.ts (grep count 0 on each); its importers are analytics.zod.ts, ui/dataset.zod.ts and the new pin. The 14-file list carries no api-surface, authorable-defaults or json-schema.manifest artifact; the inferred type of both keys stays string. Lint & Repo Gates (the carrier of check:api-surface, check:export-origins, check:declaration-map, check:generated, check:migration-registry, check:adr-0087-registration) is green on this head.
  5. Refusal path, code and prescription — RIGHT. .regex() with an error callback yields invalid_format at dimensions.N.field / measures.N.field, pinned on DatasetSchema, both member schemas alone, the dataset write-door binding (getMetadataTypeSchema('dataset') === DatasetSchema) and defineStack (STACK_SCHEMA_INVALID / 422 at datasets.0.measures.1.field). Both prescriptions open with the contract sentence and name the ADR-0021 form (the structured filter, derived: { op, of: [...] } with the 0–1 scale; group by the column or keep the bucket as a field). The shared door sentence ("A SQL expression there … the analytics dataset door refuses it on every query") has the expression as its subject and is true of one; an '' author reads "a count may also omit field" in the same message. No tracker number in a runtime string. The describe() texts and the regenerated references/ui/dataset.mdx rows match.
  6. ADR-0087 disposition — RIGHT on this head; the first review's FAIL reason is closed. The delta, item by item:
    • The D2 conversion dataset-count-measure-empty-field-removed in MAJOR_18_CONVERSIONS: toMajor: 18, retiredFromLoadPath: true, retiredAfter: '17.5.0'. ADR-0087's pre-GA policy (the window exemption "covers the window only — never the chain: … a retiredFromLoadPath conversion when lossless, and one D3 semantic entry per retirement family in every case; D2 carries the mechanical data repair only") is met in that exact shape; the precedent time-default-utc-suffix-dropped on origin/main carries the same three values, and 17.5.0 is packages/spec/package.json's version, which retired-after.census.test.ts prescribes for an unpublished entry.
    • Scope — count + '' only, RIGHT. The predicate is isDict(m) && m.aggregate === 'count' && m.field === ''. The repair is lossless: on origin/main the compiler emits sql: m.field ?? '*' (dataset-compiler.ts:721), so dropping the key yields COUNT(*), the row count the '' meant; the fix(service-analytics)!: refuse a caller-supplied analytics member that is not a column reference at the door #21190 door's m.field !== '' guard (analytics-service.ts:2087) never judged it. The controls left as stored are each right: a non-count '' was already refused on origin/main by the refinement !m.field && m.aggregate !== 'count' (dataset.zod.ts:429), so it had no working row; a dimension '' answered 500 on both strategies and groups by nothing; an expression has no mechanical rewrite. The stored-row pin proves each control returns the same reference with no notice. A padded value would trim mechanically, but no producer writes one; the D3 surface names "a padded or empty string", so an author who finds one gets the prescription. Noted, not owed.
    • Mechanics. mapCollection(stack, 'datasets', …) over the measures array; stripKeys(m, ['field'], emit, path) matches the helper's (item, keys, emit, path) signature and emits one field → (removed) notice per removed key; expectedNotices: 2 for the two blank counts; idempotent (a removed key no longer equals ''). The fixture is disjoint (deal_metrics), before → after moves only the two blank counts, and the table-wide replay in conversions.test.ts asserts the whole table lands on exactly this after (it does not require after to parse, so the two honest "left as stored" rows are allowed). Placement: list order 54 (main's highest is 53, formFieldPublicPickerRemoved; no 54 existed), the row at its identifier's sort position between dashboardWidgetChartConfigStructureRemoved and elementFilterRemoved, the definition directly above elementFilterRemoved's — the rule conversions-major18-merge.test.ts enforces.
    • The D3 link. The entry file 18.dataset-member-field-expression-refused.ts carries conversionIds: ['dataset-count-measure-empty-field-removed'], the shape 18.time-default-zone-refused.ts uses; the regenerated semantic:18 region of migrations/registry.ts carries the identical text; step18.conversionIds is computed from CONVERSIONS_BY_MAJOR[18] (registry.ts:6129), so the new id is listed without a hand edit. Both are pinned (the registration case in the stored-row test and in dataset-field-column-reference.test.ts). No RETIRED_KEYS_BY_MAJOR row: no key left the shape; pinned.
    • The rationale fragment. Hand-written, outside the generated regions (STEP18_RATIONALE at :5024; the generated semantic:18 region opens at :6134), order: 58. Verified: origin/main's fragments run to two 56s (so uniqueness is not required), and PR feat(spec)!: record:line_items gets its ComponentPropsMap row, its columns are the inline grid column contract, and the showcase Tasks grid binds by name (#21142) #21244 (open at my read) adds order: 57 in its own registry.ts patch. The ids stay sorted (dashboard-widget-chart-config-structure-refused before it, duration-keys-unit-in-key after; a C-locale sort check over the whole list passes), which is the one thing step18-rationale-merge.test.ts requires.
    • The corrected sentences, each checked where it lives. D3 reason: the door "refuses one [an expression] with a 403 refusal, inline or saved … That door never judged an empty field — it skips one", and "One empty string had a working row and has a lossless repair … the D2 conversion … drops it from stored rows and sources. Everything else has no mechanical rewrite" — TRUE. D3 acceptanceCriteria: "A count measure that carried field: '' loses the key by the D2 conversion, parses, and still counts rows; a non-count measure or a dimension with an empty field is left as stored" — TRUE, pinned. Changeset: "That door never judged an empty field: it skips one, which is how a count measure with field: '' kept counting rows on SQLite's native-SQL path (the D2 repair below)", "D2 carries the one lossless repair … The D3 entry … carries the rest" — TRUE. STEP18 fragment: "the analytics dataset door already refused one [an expression] on every query", "The one lossless repair is D2 … the semantic entry … carries the rest" — TRUE. Liveness notes: the measure note names the D2 and the D3, the dimension note says "a dimension has no D2 conversion (no lossless rewrite)" — TRUE. dataset.zod.ts comment: "It never judged an empty field (it skips one); a stored count measure with field: '' is repaired on load by the D2 conversion" — TRUE. PR body: the "no stored row worked before" sentence is withdrawn in writing ("Correction. … That is false by this PR's own probe table"), and the Patch round 2 section states the D2/D3 split. A grep of the whole diff finds no residual "no D2" or "worked before" sentence.
    • Two base merges. 494acb02 merges ef96c9ed and a3514395 merges 3dc33b2d (parents read from git); the regenerated region is consistent with the entry file and check:migration-registry / check:generated are green inside Lint & Repo Gates. Whether os-regen-merge.sh drove the merges is not visible from the diff; its required outcome is. origin/main has since moved one commit (d150c303, docs(metadata-protocol): re-anchor the remaining dead tracker and comment-id citations in test comments (stage 2 of #20595) #21246: metadata-protocol test comments; none of this PR's 14 paths), which the queue leg re-merges.
  7. Liveness rows — RIGHT. dimensions.field and measures.field stay live at the same evidence sites, verifiedAt 2026-10-01, the narrowing and the D2/D3 split recorded in each note (item 6). Spec property liveness green.
  8. Consumer tests — re-pinned, not loosened; unchanged since the first review and re-read on this head. service-analytics inline-dataset-field-admission-door.test.ts: the expression fixtures are built unparsed through storedDatasetWith (the stored-row shape the door is defence in depth for), every 403 door pin keeps its inputs and assertions, and a new case pins the contract refusing both fixtures at dimensions.0.field / measures.0.field. rest analytics-16019-driver-declared-fault.test.ts: the inline and saved expression cases move 403 → 400 and gain invalid_format at the path inside detail, execute never called, no expression text echoed; the saved branch does parse after stripReadDecorations (rest-server.ts:11068 and the DatasetSchema.parse that follows), so the 400 is the route's own. The two disclosed narrowings (toContain('lowered_name') dropped; the statement-leak check made case-sensitive on \bSELECT\b|\bGROUP BY\b because the prescription's prose says "Group by the column itself") stay acceptable: both strategies emit upper-case keywords and the expression-text checks stay case-insensitive.
  9. Published prose, the rest. Changeset level, banner, Clause-②: yes (narrowing), the marker naming both ids — TRUE; the FROM → TO block with field: '' → omit and the CASE measure → filter — TRUE; "tsc does not: the key's type is still string" — TRUE; the route "now answers 400 VALIDATION_FAILED at the path where it answered 403" — TRUE; "os migrate meta --from 17 lists, and every stored-row rehydration replays, the D2 conversion" — TRUE for a retiredFromLoadPath entry (applyConversionsToStoredItem pins includeRetired: true); "A NEW save of that shape is refused at the save door … the write path parses with the current schema and replays no conversion" — TRUE under ADR-0087's load-path retirement, and consistent with the write-door pin. content/docs/data-modeling/analytics.mdx bullet — TRUE. Generated references/ui/dataset.mdx — the describe() texts.

② Semver level

'@objectstack/spec': minor with the BREAKING banner, Clause-②: yes (narrowing), the FROM → TO migration, and exactly one marker adr-0087: registered dataset-member-field-expression-refused, dataset-count-measure-empty-field-removed — the comma-separated registered form the gate documents, and both ids are new in this diff, so registered is the honest disposition. The level is what ADR-0087's amended pre-GA rule prescribes ("a metadata-facing retirement or break ships minor, carrying the BREAKING banner and its ADR-0087 disposition entry"); check-changeset-no-major and Check Changeset are green. The PR body carries Clause-②: yes; the arm lives in the changeset, where check:adr-0087-registration reads it. Level and disposition are now both complete.

Check-runs on fc4e91c0 at my final read: 42 runs, all concluded, none in_progress, none red. Green (38): Auto Label, Build Core, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate (rollup, 1/3, 2/3, 3/3), Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance (live PG + MySQL), Test Core (rollup and all six shards), The card this PR closes must claim this branch, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check, filter. Skipped (4, not a pass): Console Pin Gate and Packed-tarball smoke (opt-in), path-filtered; one duplicate-trigger instance each of Auto Label and Check PR Size, whose other instance is green. The file list touches no governed surface, so this record is owed for Clause-②: yes and the published schema, not for Tier S.

③ Boundary flags

  • Round-1 flags, carried. The dimension refusing '*' (ANSWERED, ①.1); the file surface beyond the claim — the non-public module, two consumer tests, one analytics.mdx bullet (ANSWERED, each the narrowing's own consequence); the PR body's Clause-②: yes without the arm (ANSWERED, ②); the attribution trailer form (ANSWERED, AGENTS.md's model-free pair governs). None moved this round.
  • The first review's escalation — the data-at-rest half of the seat answer 5940285278. ANSWERED on this head: the seat's response 5940653144 reads it as decided by ADR-0087's pre-GA policy text rather than needing a ruling, and the policy text says exactly that (①.6). The D2 conversion is in the diff, so the tolerated legacy spelling now lives where Prime Directive Add comprehensive test suite for Zod schema validation #12 puts it: declared, tested, and expiring on retiredAfter. The ordering answer A (land on its own) stands; nothing in this round changed the pinned objectui build's inputs.
  • Patch-round deviations. PR body updated through the relay's issue_patch and without a footer block — channel matters, not contract; the stored body is what I read. Push ordering — the WIP head fc4e91c0 was pushed before the verification chain, and the check-runs are on that same head, so CI and the dev's readings describe one commit. Conversion order 53 → 54 — RIGHT (①.6). Two base merges through the regen script — the outcome verified (①.6).
  • The one out-of-scope note. A NEW Studio save of a count measure with field: '' is still refused at the save door; the conversion repairs stored rows only. ANSWERED — that is the correct split: the producer is fixed at the producer (Prime Directive Add comprehensive test suite for Zod schema validation #12), and app-shell(DatasetDefaultInspector): new dataset rows are seeded with field: '', which objectstack's narrowed dataset schema refuses at save; write no field when the box is blank objectui#11402 (read for this act: open, titled on the DatasetDefaultInspector seed at :551 / :601) carries it. Until it and a .objectui-sha bump land, a Studio author of a plain count who leaves the Field box blank gets the prescription to omit the key; that cost is stated in the seat answer and the changeset.
  • Carried notes, unchanged. A sum over '*' → the count-only boundary in spec+service-analytics: retire the cube metric types number / string / boolean — they existed to carry a SQL expression, which #20943 retired from a member's sql #21000's family. A padded field → refused at parse, no conversion owed for want of a producer (①.6).

Implemented-by: claude/issue-21220-dataset-field-column-reference
Reviewed-by: session_01UtnxvdiN376GF3sgXwAw4d

VERDICT: PASS

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

Labels

Projects

None yet

2 participants