Skip to content

fix(lint)!: os validate refuses a dataset dimension over a JSON-stored field, and count_distinct over a field declared multiple: true - #21073

Merged
os-justin merged 6 commits into
mainfrom
claude/issue-20890-dataset-dimension-leg
Oct 1, 2026
Merged

os-justin merged 6 commits into
mainfrom
claude/issue-20890-dataset-dimension-leg

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Closes #20890
Clause-②: yes (narrowing)

What changes

@objectstack/lint's dataset-member rule (packages/lint/src/validate-dataset-measure-aggregates.ts) gains the authoring-time legs the analytics door already enforces at query time:

  1. New rule id dimension-json-stored-field-refused (gating, error). A dataset dimension whose field resolves to a structured-JSON field (STRUCTURED_JSON_TYPES) or a multi-value field (isMultiValueField) is refused at datasets[N].dimensions[M].field. These are the door's two predicates (service-analytics structured-json-dimension-door.ts), imported from @objectstack/spec/data; lint writes no type list.
  2. measure-aggregate-field-type-refused reads the declaration: count_distinct over a multi-capable type flagged multiple: true is refused. One helper, acceptsDeclaration (the table row AND isMultiValueField), decides both the verdict and the hint.

Both legs ride the existing registry entry, so they reach os validate, os build, os lint and the runtime dataset write door. A dotted relationship.field is judged on the leaf's object.

Declared beyond the claim:

Docs: content/docs/deployment/validating-metadata.mdx §6 now states the multiple: true declaration and the dimension leg (added after the at-tier review flagged the page).

Not included: cube dimensions. No rule in packages/lint walks analyticsCubes; that gap is filed separately.

os validate, before (6073bb96b8) and after (c953ad2a58)

fixture before after
dimension over json exit 0 exit 1
dimension over text (control) exit 0 exit 0
measure avg over json (control) exit 1 exit 1
count_distinct over select with multiple: true exit 0 exit 1
count_distinct over a single select (control) exit 0 exit 0
dimension over tags exit 0 exit 1
dimension over select with multiple: true exit 0 exit 1
dimension over a single select (control) exit 0 exit 0
analyticsCubes dimension over json (not in scope) exit 0 exit 0

examples/app-todo, app-crm and app-showcase pass os validate after the change, with no finding of either id.

Tests and gates

The rule's tests and ablations (A1–A4, each red) and the local runs are in the os-dev-report on #20890. dispatch-gates.mjs --commands at acde186204: 60 derived, 60 run, all exit 0.

Changesets

Two, each minor for @objectstack/lint with a BREAKING banner and one ADR-0087 disposition:

  • 20890-dataset-dimension-json-stored-refused.md: not-required (no-migration-prescription).
  • 20890-dataset-distinct-multiple-refused.md: not-required (already-registered dataset-measure-aggregate-field-type-refused).

Acceptance notes

  • The ADR-0087 entry dataset-measure-aggregate-field-type-refused names the JSON-stored types but not the multiple: true declaration. Widening that sentence is a packages/spec edit outside this card. Carrier: none.

Generated by Claude Code

claude added 4 commits October 1, 2026 04:24
…count_distinct over a field declared multiple: true

The dataset-member rule gains the dimension leg the measure already had: a
dataset dimension whose field is structured JSON (STRUCTURED_JSON_TYPES) or
multi-value (isMultiValueField, the declaration with its multiple flag) is
refused as dimension-json-stored-field-refused, the two predicates the
analytics door refuses a grouped member by. The measure leg reads the
declaration as well as the type: count_distinct over a multi-capable type
flagged multiple: true is refused, as the compile leg and the engine's
count_distinct door refuse it.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…usal, not that nothing can select it

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…e rule-id barrel contract requires

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/ai/natural-language-queries.mdx (via count_distinct (literal, a string literal in acceptsDeclaration))
  • content/docs/data-modeling/queries.mdx (via count_distinct (literal, a string literal in acceptsDeclaration))
  • content/docs/deployment/validating-metadata.mdx (via count_distinct (literal, a string literal in acceptsDeclaration))
  • content/docs/kernel/contracts/data-engine.mdx (via count_distinct (literal, a string literal in acceptsDeclaration))
  • content/docs/protocol/objectql/query-syntax.mdx (via count_distinct (literal, a string literal in acceptsDeclaration))
  • content/docs/ui/dashboards.mdx (via count_distinct (literal, a string literal in acceptsDeclaration))

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

  • content/docs/releases/v15.mdx (via count_distinct (literal, a string literal in acceptsDeclaration))
  • content/docs/releases/v17/17-0.mdx (via count_distinct (literal, a string literal in acceptsDeclaration))
  • content/docs/releases/v17/17-5.mdx (via count_distinct (literal, a string literal in acceptsDeclaration))

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/lint/src/index.ts) — pages documenting those are invisible to this run
  • 1 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 — 4 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 94608a7d72ecef7bf61d10dbab3bd80aea311055 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 94608a7d72ecef7bf61d10dbab3bd80aea311055

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

…s the seat ruled

The package entry gains an export and the accept set narrows, so the
declaration is yes with the narrowing arm. Only the Clause-② line moves.

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: be20968ee2873689ef988ee11c681298ca6e8e37
Local-runs: none

Inputs read: card #20890 (body, comments 5914918310, 5923271558, 5924574345 as corrected, 5925066047, 5925100456, 5925153068), card #21082, PR #21073 (body, file list, the Docs Drift Check comment), the net diff of the head against main (merge base 6073bb96b8; 5 files, +467 / -26, five commits), the door packages/services/service-analytics/src/structured-json-dimension-door.ts and its call site in analytics-service.ts, the compile leg dataset-compiler.ts, the spec helpers in packages/spec/src/data/field-value.zod.ts and aggregate-field-type-compatibility.ts, packages/lint/src/object-graph.ts, the registry entry in authoring-rules.ts, the barrel test, the runtime authoring gate, the shipped datasets and their objects, the ADR-0087 entry, scripts/pm/clause2-line.mjs, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, the hand-written docs the drift check listed, and the check-runs on the head, read last. Nothing was built, run or re-run; every "measured" number below is the dev's and is marked so.

① Derived judgments

Accept-set changes the diff implies

  1. New gating rule dimension-json-stored-field-refused (severity error, path datasets[N].dimensions[M].field): a dataset dimension whose field resolves on the object graph to a leaf declared with a type in STRUCTURED_JSON_TYPES, or for which isMultiValueField({ type, multiple }) holds, is refused. Right. Read against the door's groupedClassOf: the same two predicates imported from @objectstack/spec/data, in the door's order (structured-JSON first, then multi-value), on the same declaration shape. The multiple half is read identically on both sides: the graph stores it only when def.multiple === true (object-graph.ts:324) and the lint shapeOf reads verdict.meta?.multiple === true; the service hands the door { type: meta.type, multiple: meta.multiple === true } (analytics-service.ts:1285, :2792). Lint writes no type list; the two sweep pins compare the verdict to the spec predicates over every FieldType, flagged and unflagged.

    • The leaf the dimension resolves to. Dotted paths resolve hop by hop to the leaf's own object (resolveFieldPath), and the finding names that object. Skips: no base object, an object the stack does not define or that declares no field map (unknowable), hop-untargeted, the other hop-* verdicts and field-unknown (those are dataset-field-unknown's), a leaf with no meta.type, a dimension with no string field. The door's own stand-downs are the same tier: no sourceFieldMeta, a non-bare cube sql, a dotted path with no declared join, a leaf with no string type. Where lint judges a dotted path the door would not, the compiler does: a dataset dimension a.b whose relationship is not in include is refused 400 DATASET_INVALID by assertDeclared (dataset-compiler.ts:655-670), and one that is in include has its join alias registered, so the door's columnOf reaches the leaf. No dotted dimension this leg refuses is served. Right.
    • The one place the leg refuses more than the door's literal predicate. The door judges GROUPING members only: dimensions entries and timeDimensions entries that carry a granularity; a selection that uses a dimension purely as a timeDimensions window with no granularity is not judged there. The leg refuses the declaration regardless of how a selection later uses it. Right, and named so the seat sees it: DatasetDimensionSchema publishes dimensions as "Groupable axes", the card's direction is a declaration-level refusal by os validate, and a date window over a JSON-stored column is not a use the door answers meaningfully. The rule header states this argument ("the one ungrouped use of a dimension bounds a date, which a JSON document is not"), and it is true of the door's groupedMembers.
    • An off-vocabulary type answers null on both sides (neither groupedClassOf nor the lint groupKeyClassOf carries a DECLARED_FIELD_TYPES guard for grouped members). Right.
  2. measure-aggregate-field-type-refused reads the declaration: count_distinct over select, radio, lookup, user, file or image declared multiple: true is refused, through acceptsDeclaration = the table row AND NOT (count_distinct AND isMultiValueField). Right. It is the compile leg's flaggedList (dataset-compiler.ts:412-418) and the door's distinctClassOf, spelled once and driving both the verdict and the hint, so the hint's accepted list for a flagged select is count alone. Only count_distinct can move: the sum / avg / min / max rows accept no multi-capable type, and the subtraction is count_distinct-only, so count over a flagged field is untouched (pinned). The door's extra guard (an off-vocabulary type is not judged for count_distinct) has no lint twin, but the fail-closed table already refused an off-vocabulary type on this rule before this PR; that is not this diff's.

  3. Reach through the registry entry (unchanged, authoring-rules.ts:691-723): tier: 'gating', input: 'parsed', commands: ALL, surfaces: CLI_AND_RUNTIME, runtimeTypes: ['dataset']. So both legs reach os validate, os build and os lint (a gating finding exits 1) and the runtime dataset write door, where a gating finding is the 422 of runtime-authoring-gate.ts:1020, which fronts Studio's designer, REST /meta item CRUD and MCP writes (its header). Right; pinned through runAuthoringRules on all three commands, and the dev ran the metadata-protocol dataset-writes test against lint rebuilt at acde186204 (5 passed, the dev's reading).

  4. Shipped metadata in the tree: none fails. Enumerated from the declarations: platform-objects system datasets — four with dimensions: [], sys_audit_log_metrics over action (Field.select, no multiple) and user_id (Field.lookup); app-crm opportunity_metrics over stage (select), account (lookup), close_date (date); app-showcase four datasets over single select, lookup, date, progress and the injected created_at; app-todo task_metrics over three single selects, a lookup, a date and a datetime. The only multiple: true fields in those objects (showcase_project.team_members, todo_task.tags) are not dimensions, and no shipped dataset declares a count_distinct measure, so the measure leg moves nothing. The build probes (metadata-protocol/src/build-probes.ts:336) and the dataset create seed (metadata-create-seeds.ts:96) carry dimensions: []. The dev's corpus reading (examples exit 0, 0 findings of either id) agrees, and Test Core, Dogfood Regression Gate and Dogfood Verify CLI are green on the head.

  5. Cubes are not judged, by design: analyticsCubes appears once in packages/lint/src outside tests (validate-field-consumers.ts:197, a field-removal census root). The exclusion is right against triage's "if lint reads cubes", and the gap is card [finding] os validate passes an analyticsCubes member the analytics door refuses: lint reads no cube, so a cube dimension over a JSON-stored field passes authoring and is refused 400 at query time #21082 (filed bare; no labels at the read).

Public-surface changes the diff implies

  1. packages/lint/src/index.ts exports DIMENSION_JSON_STORED_FIELD_REFUSED — a widening of the published barrel. Right and required: rule-id-barrel-exports.test.ts demands every rule-id constant be reachable from a published entry, and runtime.ts re-exports no id from this file, so index is the only entry that can carry it. DatasetMeasureAggregateFinding.rule widens to a two-member union. Both are declared in the first changeset.

Pins, read not run

  1. Deleting the structured-JSON branch reds the json pin, the STRUCTURED_JSON_TYPES sweep and the full-surface sweep; deleting the multi-value branch reds the multi-value test and the sweep; ignoring the flag reds the flagged-select pin and the flagged sweep; the controls (a text dimension, an unflagged select, count over a flagged field, every unflagged type the predicates accept) red an over-refusal. The sweeps are floored on both sides (refused above 15 and accepted above 50 for dimensions; both above 50 for the flagged measure sweep), so an empty loop cannot pass. Removing the whole dimension leg reds seven tests including the runAuthoringRules one. Right. The dev's ablation table (A1 to A4 on c953ad2a58) is consistent with this reading and was not re-run here.

Every sentence the diff adds, tested against the tree

② Semver level

  • Both changesets grade @objectstack/lint minor with a BREAKING banner and a bang in the headline. Right: the launch-window convention ships an accept-set narrowing as minor with the banner as the breaking-ness carrier (check-changeset-no-major.mjs), the diff moves packages/lint/src/**, and nothing is graded patch.
  • Clause-②: yes (narrowing) against scripts/pm/clause2-line.mjs: yes (narrowing) is "a diff that widens one surface and narrows another; both facts are read". This PR narrows the accept set (metadata accepted today is refused on four surfaces) and widens the public surface (a new barrel export and a wider rule union). The level axis reads the PR body's line, which reads yes (narrowing). Right. One note for the record: the second changeset's own diff widens nothing by itself, so as a per-changeset statement no (narrowing) would be the tighter spelling; no gate reads the arm per changeset against the diff, the lint sibling's shipped changeset used yes (narrowing) for the same shape, and check-adr-0087-registration read both files as [BREAKING+bang+clause-②-narrowing]. Not wrong.
  • ADR-0087 dispositions against the gate's CATEGORIES: changeset 1 not-required (no-migration-prescription) — the body carries route prose and no FROM-to-TO rewrite, and the door's own changeset used the same category for the same surface; changeset 2 not-required (already-registered dataset-measure-aggregate-field-type-refused) — the id resolves at HEAD and at the merge base (packages/spec/src/migrations/entries/semantic/18.dataset-measure-aggregate-field-type-refused.ts), which is the category's precondition. Both right; Check Changeset and Lint & Repo Gates are green on the head.

③ Boundary flags

  • Q1 (Clause-② arm) — ruled C by the seat (5925100456); the head carries yes (narrowing) in both changesets and the PR body (patch-round report 5925153068). Answered.
  • Q2 (the barrel line and the multi-value dimension) — ruled A pending this review. Ratified here: the barrel line is the package's own contract test, and the multi-value refusal is the door's second predicate in the same function, so a type-only leg would mirror half the door and pass a select with multiple: true that the door refuses. Answered.
  • Deviations: the file surface gains packages/lint/src/index.ts (ratified above); two changesets instead of one (each carries exactly one ADR-0087 marker and the two halves have different true categories; right).
  • out_of_scope 1 (cube members unjudged at authoring) — card [finding] os validate passes an analyticsCubes member the analytics door refuses: lint reads no cube, so a cube dimension over a JSON-stored field passes authoring and is refused 400 at query time #21082 filed bare. Answered.
  • out_of_scope 2 (the ADR-0087 entry's surface and acceptance prose does not name multiple: true) — carrier none, noted in the PR's Acceptance notes. Escalated as a standing gap: the second changeset's already-registered rides an entry whose prose stops at the JSON-stored TYPES; the fix is a one-sentence widening in packages/spec, outside this card's surface. The seat decides whether it needs a card.
  • New flag from this review — doc drift: content/docs/deployment/validating-metadata.mdx:209-212 misstates the rule at landing (see ①). The PR's Docs Drift Check listed the page; nobody acted on it. The page is hand-written, not release-owned, so the edit is one sentence either on this branch before enqueue or in a docs-only follow-up. Escalated to the seat. Under the Documentation Guardrails the drift check is advisory, so this is not a landing blocker and the verdict stands.
  • Patch-round coverage: the 49 lint-keyed gate families the dev did not re-run at be20968ee2 are answered by the head's check-runs (Test Core, Type Check, Lint & Repo Gates, Dogfood: success).

Check-runs on be20968ee2, read last, deduped by name keeping the newest started_at: 34 runs, 34 completed, 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failure, none still running.

Implemented-by: claude/issue-20890-dataset-dimension-leg
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-10-01T05:38Z · 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.


Generated by Claude Code

…or count_distinct, and judges dimensions

The page's account of measure-aggregate-field-type-refused now says that
count_distinct over a multi-capable field declared multiple: true is
refused, and that dimension-json-stored-field-refused refuses a dataset
dimension over a structured-JSON or multi-value field.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l and removed size/m labels Oct 1, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Delta re-review of PR #21073 on its new head. The prior record (5925454166, PASS at be20968ee2) flagged that content/docs/deployment/validating-metadata.mdx §6 misstates the rule once this PR lands; the seat's adoption folded that flag as a new head. Inputs read: card #20890 (body; comments 5914918310, 5923271558, 5924574345 as corrected, 5925066047, 5925100456, 5925153068, the docs-round report 5925642491), the prior record and its adoption note on the PR, card #21082, PR #21073 (body, file list, the Docs Drift Check comment), the net diff against main at the merge base 6073bb96b8 (6 files, +483 / -28, six commits), the one-commit delta git diff be20968ee2 b0afb0d7, the page at the head in full, the rule source packages/lint/src/validate-dataset-measure-aggregates.ts, packages/lint/src/object-graph.ts, the registry entry in authoring-rules.ts, the spec helpers packages/spec/src/data/field-value.zod.ts and aggregate-field-type-compatibility.ts, the analytics door packages/services/service-analytics/src/structured-json-dimension-door.ts and the compile leg dataset-compiler.ts, PRs #20886 and #21019 and the door's two changesets on main, the ADR-0087 entry 18.dataset-measure-aggregate-field-type-refused.ts, scripts/pm/clause2-line.mjs, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, and the check-runs on the head, read last. Nothing was built, run or re-run; every measured number below is the dev's and is marked so.

① Derived judgments

The delta, bounded. git diff be20968ee2 b0afb0d7: one file, content/docs/deployment/validating-metadata.mdx, +16 / -2, one commit (docs(validating-metadata): the dataset rule reads the multiple flag for count_distinct, and judges dimensions). The other five files are byte-identical to be20968ee2 by blob id: .changeset/20890-dataset-dimension-json-stored-refused.md 46095ee43b, .changeset/20890-dataset-distinct-multiple-refused.md fb261ac537, packages/lint/src/index.ts 2495e09adb, packages/lint/src/validate-dataset-measure-aggregates.test.ts b78fb603d3, packages/lint/src/validate-dataset-measure-aggregates.ts 417ce04e62. main has not moved since the merge base on any of the six paths or on the door file (the diff from 6073bb96b8 to origin/main 63d1a7c378 over those paths is empty; the door blob 1915a5a420 is identical on main and the head). The PR body gained one line (the Docs: sentence). So the accept-set and public-surface judgments of the prior record are out of this delta's reach; they are re-read below, not re-derived.

Each new or changed sentence of §6, tested against the tree at the head. The sources are the lint rule (lint:), field-value.zod.ts (fv:), aggregate-field-type-compatibility.ts (aftc:), the door (door:) and the compiler (dc:).

  1. "The check reads the field's declaration as well as its type" — true. shapeOf builds { type, multiple: verdict.meta?.multiple === true } (lint:260-262) from the leaf the graph resolved, and the graph stores multiple only when the author wrote true (object-graph.ts:324); acceptsDeclaration (lint:240-243) judges that shape.
  2. "a select, radio, lookup, user, file or image field declared multiple: true holds a list stored as JSON" — true. The six are exactly MULTI_CAPABLE_TYPES (fv:335-337), the set isMultiValueField reads beside MULTI_OPTION_TYPES (fv:355-358), and the helper is what the rule calls. "Stored as JSON" is the spec's own ground: aftc's header names a multi-capable type flagged multiple: true as the one other JSON-stored shape, and the door's declaredValueShape doc says the same. The aftc prose happens to name five of the six (it omits radio); the page follows the helper, which names six, and that is the right source.
  3. "so count_distinct over it is refused too, although the table accepts the type" — true, and exact. acceptsDeclaration answers false for count_distinct when isMultiValueField(shape) holds (lint:242); the count_distinct row is every FieldType minus the ten JSON_STORED_AGGREGATE_FIELD_TYPES (aftc:190-198), and none of the six is among the ten, so the row accepts each. The finding's own message says "none of them with multiple: true". True on main the moment this PR lands; the sentence describes this PR's rule.
  4. The pre-existing continuation, "The analytics service refuses the same pair with 400 DATASET_INVALID when a query is built; this is the identical verdict, from the identical table, one door earlier", now has the flagged pair in its antecedent. "The same pair ... 400 DATASET_INVALID" — true: dc:406-418 (flaggedList = isMultiValueField(shape) and the row accepts the type, refused DATASET_INVALID; PR fix(service-analytics)!: the analytics door refuses a multi-value grouping and a JSON-stored count_distinct, judged on the declaration, as the engine does #21019, merged 2026-10-01T03:53Z, on main). "From the identical table" — over-narrow by a word for the flagged pair: that verdict comes from the same spec helper beside the same table row, not from the row, as the page itself says one sentence earlier ("although the table accepts the type"). Not false (both legs read the identical table and the identical helper, and the verdict is identical), and the page's register tolerates it; a one-word tightening is available to the seat and is not a landing matter. The compile leg's early return on a dotted field (dc:401) means lint judges a dotted flagged path the compile leg does not, which was already so for every pair on this page before this PR and is documented in the rule header.
  5. "The same check judges the dataset's dimensions, under its own id" — true. The dimension walk is inside validateDatasetMeasureAggregates (lint:309), the finding's rule is DIMENSION_JSON_STORED_FIELD_REFUSED (lint:337), spelled dimension-json-stored-field-refused (lint:185).
  6. "A dimension is a group key, and the analytics service refuses a query grouped by a JSON-stored column with 400 INVALID_FIELD before any SQL is built" — true at the head and on main now. dc:673-695 files each dataset dimension as a cube dimension with sql: d.field; the door refuses the first grouped member whose column is structured-JSON or multi-value through invalidMemberError (INVALID_FIELD / 400, door:253-267, :295-326), in ensureCube ahead of strategy selection ("before either strategy builds anything", door:3-10). "A query grouped by" is the door's own predicate (a dimensions entry, or a bucketed timeDimensions entry), so the sentence is as precise as the door. The door's stand-downs (no sourceFieldMeta, a non-bare cube sql, a dotted path with no declared join) do not reach a dataset dimension the compiler files, as the prior record's ①.1 established; the sentence's generality is the same the page already uses for the measure pair.
  7. "So a dimension whose field is declared with a structured-JSON type, or as a multi-value field (multiselect / checkboxes / tags, or one of the types above declared multiple: true), is refused (dimension-json-stored-field-refused) at datasets[N].dimensions[M].field" — true. groupKeyClassOf (lint:279-282) asks STRUCTURED_JSON_TYPES.has(type) then isMultiValueField(shape), in the door's order; multiselect / checkboxes / tags is MULTI_OPTION_TYPES (fv:146); "the types above" resolves to the six named one paragraph up, which is MULTI_CAPABLE_TYPES; the path is lint:339. "The structured-JSON types" is left unlisted, which is the page's pre-existing register for that class (:211).
  8. "Group by a field that stores one value instead" — true, as the common denominator of both hints: lint:352 ("Group by a field that stores one scalar value ...") and lint:355-358 ("Point this dimension at a field that stores one value, or remove it"). The multi-value hint's first route (filter by one member with $contains) is not restated; the sentence claims no more than the hints do.
  9. "Like the measure check, it stays silent when the field's type cannot be resolved" — true. lint:316-321 (an unresolved or unjudgeable path; a leaf with no declared type), the per-dataset object skip both legs share (lint:302-304), and a dimension with no string field (lint:312-313). The measure paragraph's own list (an object this stack does not define, a dangling field path, an untyped field) holds for the dimension leg one for one.

Neither new passage carries a tracker number; the page's one (#16354) is pre-existing and sits in the doors table, not in refusal prose. check:doc-authoring and the doc-* families run under Lint & Repo Gates, which is green on the head.

The rest of §6 and the page's account of this rule. A grep of the page at the head for the family (measure-aggregate, dimension, DATASET_INVALID, INVALID_FIELD, count_distinct, JSON-stored, multiple) returns §6 and the doors-table row only.

  • "A pair outside it is refused (measure-aggregate-field-type-refused) naming the aggregate, the field, its declared type and the accepted set" — still true; the flagged message names the declaration with its flag and the accepted set.
  • "count_distinct over every type but the JSON-stored ones (the structured-JSON types and multiselect / checkboxes / tags)" — the table row's statement, now read as the type half with the declaration half in the next sentence. True.
  • "The rule stays silent wherever the field's type cannot be resolved ..." — true, unchanged.
  • The doors-table row (:442 at the head, :428 on main): "Dataset measure aggregate × the field's declared type — a pair the spec's compatibility table refuses, e.g. avg over a datetime field (lint: refuse an incoherent dataset measure (aggregate × field type) at authoring time, from the spec's compatibility matrix (lint leg of #16099) #16354) | ✓ | ✓ | ✓ | — |". Two separate facts, as the dev's docs-round report reads them. (a) The runtime publish cell "—" was already false on main before this PR: the registry entry has carried surfaces: CLI_AND_RUNTIME, runtimeTypes: ['dataset'] since [finding] a runtime-created dataset reaches ZERO author-time rules — the type declares allowRuntimeCreate: true while nothing declares it in runtimeTypes and TYPE_TO_STACK_KEY has no row #19143 (commit 58644ad596, 2026-09-20); the row was last edited in 362dcc38f3 (2026-09-18); the table's legend has no dataset superscript, so the fix is a legend-and-row edit. This PR neither causes nor fixes that. (b) This PR makes the row incomplete, not false: the row's text is a true description of one thing the family judges and claims no exclusivity, but after this PR the same registry entry also judges dimensions and reads the declaration, so a reader of the table under-counts what the family does. Nothing the PR adds to §6 contradicts the table (§6 says nothing about the four doors). Carried to ③.

PR-body sentences at this head. The new line, "Docs: content/docs/deployment/validating-metadata.mdx §6 now states the multiple: true declaration and the dimension leg (added after the at-tier review flagged the page)" — true: the two passages are at :213-216 and :223-231 of the head; the prior record was posted 2026-10-01T05:38Z and the commit is dated 05:40:38Z. "Not included: cube dimensions ... that gap is filed separately" — true (#21082, open, filed bare). The before/after table is the dev's at c953ad2a58; the head differs from that commit by the barrel line, two changeset lines and the doc edit, none of which touches the rule, so it stands for the head's rule. The "Tests and gates" sentence names acde186204 (60 derived / 60 run); the docs-round report reads 88 / 88 at this head — the sentence is scoped to the commit it names, so it is not false, merely not the head's count, and the head's check-runs answer the rest. "Both legs ride the existing registry entry, so they reach os validate, os build, os lint and the runtime dataset write door" — true (authoring-rules.ts:691-723, unchanged). "The same door function has refused it at query time since PR #21019" — true (merged 2026-10-01T03:53Z, before the PR opened). The Changesets and Acceptance-notes sentences are unchanged from the prior record's reading and still true: both category spellings are in the gate's CATEGORIES, and the entry's surface / acceptanceCriteria prose stops at the JSON-stored types.

Prior judgments re-read, not re-derived (the delta cannot reach them; the blobs are unchanged and the door and spec helpers are identical on main and the head): ①.1 the dimension leg is the door's two predicates in the door's order on the same declaration shape, with the same skips and the one named place it refuses more than the door's literal predicate (an ungrouped date window, which a JSON document is not); ①.2 acceptsDeclaration is the compile leg's flaggedList and the door's distinctClassOf, driving verdict and hint alike; ①.3 the registry reach on all three commands and the runtime dataset door; ①.4 no shipped dataset in the tree fails either leg; ①.5 cubes are not judged, by design, and the gap is #21082; ①.6 the barrel export and the wider rule union are the only public-surface changes, both declared; ①.7 the pins and their floors. The sentence tests of the rule header, both messages and hints, and both changesets stand (the door's own changeset .changeset/20807-analytics-json-dimension-refused.md on main still carries not-required (no-migration-prescription)). The one prior flag — "the PR edits no doc" — is discharged by this head.

② Semver level

  • The delta adds a content/docs/** edit and nothing else. It publishes nothing, so no changeset is owed for it; the two changesets are byte-identical to be20968ee2, each @objectstack/lint minor with a BREAKING banner and a bang in the headline. Right: the launch-window convention ships an accept-set narrowing as minor with the banner as the breaking-ness carrier (check-changeset-no-major.mjs), the diff moves packages/lint/src/**, and nothing is graded patch.
  • Clause-②: yes (narrowing) against scripts/pm/clause2-line.mjs: on the PR body (line 2) and on line 7 of both changesets the key is line-initial, named once and not quoted, the value token yes is the first thing after the colon, and the parenthetical that follows opens with the exact arm narrowing — the reader's declared / yes / narrowing. The diff narrows the accept set (four surfaces refuse metadata accepted today) and widens the public surface (a new barrel export and a wider rule union), which is the one combination that spelling names. The docs delta moves neither axis. Right.
  • ADR-0087 dispositions unchanged: changeset 1 not-required (no-migration-prescription), changeset 2 not-required (already-registered dataset-measure-aggregate-field-type-refused); both categories are in the gate's CATEGORIES, and the named id resolves at the head and at the merge base (packages/spec/src/migrations/entries/semantic/18.dataset-measure-aggregate-field-type-refused.ts). Check Changeset and Lint & Repo Gates are green on the head.

③ Boundary flags

  • The prior record's new flag (doc drift on validating-metadata.mdx §6) — folded by the seat as this head; discharged by the one commit, every sentence of which is judged true above. Answered.
  • Docs-round report 5925642491: open_questions empty; deviations none (one file, the one the flag named, nothing else moved). out_of_scope_findings[0] — the doors-table row at :442 (carrier none, noted): answered above — the runtime publish cell was false on main since [finding] a runtime-created dataset reaches ZERO author-time rules — the type declares allowRuntimeCreate: true while nothing declares it in runtimeTypes and TYPE_TO_STACK_KEY has no row #19143, before this PR; this PR makes the row incomplete, not false. The remedy is a docs-only follow-up that gives the legend a dataset superscript and lets the row name the family's three judgments (the measure pair, the declaration half, the dimension leg); it is outside this round's one-passage scope and is not a landing matter — §6 is the page's statement of the rule and it is true at the head, and the drift check is advisory under the Documentation Guardrails. Escalated to the seat as a standing gap, not a blocker.
  • Earlier reports' flags (5925066047: Q1 the Clause-② arm, Q2 the barrel line and the multi-value dimension, the two deviations, out_of_scope 1 the cube gap, out_of_scope 2 the ADR-0087 entry prose; 5925153068: none) — answered in the prior record and unchanged at this head. Out_of_scope 2 still stands with carrier none: the entry's surface / acceptanceCriteria prose names the JSON-stored types and not the multiple: true declaration; a one-sentence packages/spec widening outside this card.
  • One optional tightening, not a flag against the verdict: §6's pre-existing "from the identical table" now follows a sentence that says the table accepts the type; "from the identical table and declaration" would say exactly what both legs read. The seat may take it on a docs-only follow-up with the row above, or leave it.

Check-runs on b0afb0d7bad6c17f8514f7697e7af02538923976, read last (2026-10-01T06:07Z), deduped by name keeping the newest started_at: 42 runs, 35 names, all 35 completed; 31 success, 4 skipped (Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failure, none still running. The families the docs delta reaches all answered success: Build Docs (skipped at be20968ee2, run at this head), Check Documentation Links, Flag docs affected by code changes, Lint & Repo Gates; Check Changeset, Test Core (6 shards and the rollup), the four Type Check jobs, Dogfood Regression Gate (3 shards and the rollup), Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard and the three claim guards are success.

Implemented-by: claude/issue-20890-dataset-dimension-leg
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-10-01T06:11Z · rendered by the seat's at-tier review subagent on this head (a delta re-review after the PASS 5925454166 at be20968ee2, whose docs flag was folded). The seat read its served tier family from the subagent transcript before posting.


Generated by Claude Code

@os-justin
os-justin marked this pull request as ready for review October 1, 2026 06:13
@os-justin
os-justin added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 5e470f8 Oct 1, 2026
46 of 47 checks passed
@os-justin
os-justin deleted the claude/issue-20890-dataset-dimension-leg branch October 1, 2026 06:36
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 size/l tests tooling

Projects

None yet

2 participants