Skip to content

fix(service-analytics)!: a cube or dataset dimension on a structured-JSON field is refused INVALID_FIELD / 400 at the analytics door, before any SQL is built (#20807) - #20886

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20807-analytics-json-dimension
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20807-analytics-json-dimension

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20807
Clause-②: no (narrowing)

What this changes

A dimension that GROUPS an analytics query, and whose column is a declared structured-JSON field (json, composite, repeater, record, location, address, vector), is now refused INVALID_FIELD / 400 at the analytics door, naming the member the caller wrote, before either strategy builds anything. "Groups" means a dimensions entry, or a timeDimensions entry that carries a granularity. It holds on POST /api/v1/analytics/query, its dry run POST /api/v1/analytics/sql, and POST /api/v1/analytics/dataset/query, on every driver.

The words, as POST /api/v1/analytics/query returns them for a cube dimension:

Dimension 'meta' on cube 'json_dim_ledger' groups by field 'meta', which object 'analytics_json_dim_ledger' declares as json — a structured-JSON value, which analytics does not group by. The query was NOT run. Group by a field that stores one scalar value: store the part you group on in a field of its own and group by that field. A JSON document is no group key the SQL dialects share: one grouped each serialized document apart, another refused the statement.

For a dataset dimension over an included relationship, the same words say groups by field 'account.hq', whose column 'hq' the joined object 'X' declares as json. The thrown error is invalidMemberError's envelope (code: 'INVALID_FIELD', status: 400, member, param, cube) plus field and object.

Landing site: packages/services/service-analytics only.

  • src/structured-json-dimension-door.ts (new): assertNoStructuredJsonDimension. The class is @objectstack/spec/data's STRUCTURED_JSON_TYPES, called, never re-listed. It is the same predicate the engine's groupBy door (packages/objectql/src/group-by-structured-json-door.ts) reads, so there is one "is this field structured JSON" test for both doors. That file is read, not edited.
  • src/analytics-service.ts: one private method, assertDimensionsGroupScalarColumns, called in ensureCube right after the dimension source-field gate on each of its three paths (inferred cube, augmented cube, declared cube). It supplies the two answers only the service has: the dimension sql a member resolves to (declaredMemberEntry, the strategies' own 'dimension' lookup, and the member itself when the cube declares none), and the column's declared type (sourceFieldMeta).
  • The column is read the way NativeSQLStrategy compiles it. A bare identifier is a column of the cube's object. A dotted identifier path (account.hq) is its last segment, on the object the cube's DECLARED join for that path names, which is the alias the dataset compiler registers and the strategy joins. A path with no declared join is a synthetic traversal and is not judged.

Before, measured on origin/main 793fb839

Through the real dispatcher-plugin route over the service AnalyticsServicePlugin composes on a real ObjectQL engine, with both of its auto-bridges live (executeRawSql to engine.execute, executeAggregate to engine.aggregate). Three rows: title x, x, y, and a different meta document per row. Drivers: SqlDriver on SQLite (better-sqlite3), and on a private PostgreSQL 16.13 started for this run.

request SQLite PostgreSQL 16
/analytics/query, dimensions: ['title'] (text, the control) 200, x 2 · y 1 same
/analytics/query, cube dimension meta (json) 200, one group per serialized document (3 groups, count 1 each) 500 DATABASE_ERROR (42883, "could not identify an equality operator for type json")
/analytics/query, dataset dimension meta_doc (over meta) 200, 3 groups 500 DATABASE_ERROR
/analytics/sql, cube dimension meta 200, the statement SELECT meta AS "meta", COUNT(*) AS "count" FROM ... GROUP BY meta same
/analytics/dataset/query, inline dataset dimension meta_doc 200, 3 groups 500 DATABASE_ERROR

Counted at the engine for the json dimension: raw SQL 1, engine.aggregate 0, on both dialects, so NativeSQLStrategy answered and the engine's groupBy door never saw the query.

A dataset dimension over a JOINED object's json field (include: ['account'], field: 'account.hq') answered the same two ways: SQLite 200 with one group per document (2 groups), PostgreSQL 500 (42883). This was measured through the service on the first fix commit b1befe2a6, which judged only bare columns, and through /analytics/dataset/query under the ablation below. The second fix commit closes it.

After, on this branch (075a46340)

Every refused row above answers 400 INVALID_FIELD naming the member (meta, meta_doc, acct_hq), with zero raw-SQL statements and zero engine aggregates for the object. The title, title_dim and acct_name controls answer x 2 · y 1 (and A 2 · B 1) from the native strategy, unchanged.

Mechanism assumptions (zone 2): which held

  • B1: held. It was reproduced on 793fb839 as the red pins. See the table above and the raw-SQL / aggregate counts.
  • B2: held. A dataset dimension compiles to a cube dimension whose key is the dataset dimension's name (dataset-compiler.ts: dimensions[d.name] = { sql: d.field }). DatasetExecutor passes selection.dimensions through as the cube query's dimensions, so the member reaching ensureCube IS the name the selection wrote. The compiler checks only the relationship path (assertDeclared).
  • B3: held. The one predicate class is STRUCTURED_JSON_TYPES, read at the engine door. The engine door's exported function takes groupBy entries and names groupBy[i], and @objectstack/objectql is only a devDependency of this package, so the constant is what is called. No edit to packages/objectql or packages/spec.
  • B4: held, measured. cube-registry.ts stores cubes and knows no field type. dataset-compiler.ts knows a dataset dimension's name and the declared type, but it compiles the whole dataset, whether or not a dimension is selected (a refusal there would refuse every selection of the dataset), and it does not see cube queries. analytics-service.ts's ensureCube is the first step every door passes through that knows both the member as written and the column's declared type, ahead of strategy selection. The GUARD and the per-face unit cases show one answer on both strategies. Before this, the ObjectQL face reached the engine door but was refused under groupBy[0].
  • B5: no CI harness runs this route live. The Temporal Conformance (live PG + MySQL) job sets OS_TEST_POSTGRES_URL for three steps only: driver-sql, metadata-protocol's live-postgres files, and runtime's cascade-delete matrix. It runs service-analytics without a URL, and no step runs @objectstack/rest's or @objectstack/runtime's analytics pins. The PostgreSQL cells therefore sit beside each HTTP pin as a named skip without the URL, like packages/rest/src/data-group-by-json-door.test.ts. They are red-capable and un-run in CI. The local PostgreSQL 16.13 runs are quoted below.
  • B6: measured no (narrowing). No new key reaches a published payload: the error envelope's members are the ones invalidMemberError and the dimension source-field gate already attach. The accept set narrows: SQLite answered 200, and it now refuses. Changeset minor, BREAKING banner, Clause-②: no (narrowing). The ADR-0087 category measured is not-required (no-migration-prescription): the package publishes, no ADR-0087 id covers a grouping target, and nothing authorable, exported or stored moves.
  • B7: reproduced. See the acceptance notes. Not fixed here.

Where the /api/v1/analytics/query pin lives, measured. That route is served by @objectstack/runtime's dispatcher-plugin (through domains/analytics.ts), not by @objectstack/rest. The REST package serves /analytics/dataset/query, and runtime depends on rest, so a REST-package test cannot reach the runtime route. The cube-face pin is therefore a new file beside the repo's other /analytics/query HTTP pins (packages/runtime/src/analytics-*.test.ts). The dataset-door pin is a new file in packages/rest/src/. Both are new pin files only.

Tests

All at 075a46340, the final commit.

  • New packages/services/service-analytics/src/__tests__/dimension-structured-json-door.test.ts: 13 passed. It covers every structured-JSON type on both strategy faces (native and ObjectQL), with the envelope (code, status, member, param, field, object) and zero raw SQL and zero aggregates. It also covers a cube key over another column, the cube-qualified spelling, a bucketed time dimension on both faces, a declared default granularity, a dataset dimension, a dataset dimension over an included relationship (judged on the joined object), an ad-hoc inferred cube, and the dry run. Controls: a text dimension is served, an unknown field keeps the existence gate's answer first, a synthetic dotted traversal is not judged, and a host without sourceFieldMeta stands down. GUARD: over every FieldType, the refused types are exactly STRUCTURED_JSON_TYPES.
  • New packages/runtime/src/analytics-json-dimension-door.test.ts (POST /api/v1/analytics/query and /sql): 8 passed, SQLite 4 and live PostgreSQL 16.13 4.
  • New packages/rest/src/analytics-dataset-json-dimension-door.test.ts (POST /api/v1/analytics/dataset/query): 6 passed, SQLite 3 and live PostgreSQL 16.13 3.
  • Red first: at 46ee85eda (the pins on the base code) the unit file was 8 failed and 3 passed, the runtime file 6 failed and 2 passed, and the REST file 2 failed and 2 passed. The failures were the refusals; the controls were green.
  • pnpm --filter @objectstack/service-analytics exec vitest run: 145 files / 3325 passed.
  • typecheck, exit 0: @objectstack/service-analytics (tsc --listFilesOnly includes the new door and the new test); @objectstack/rest (the new test is in its test program, and check:test-typecheck holds at 0 files / 0 errors); @objectstack/runtime (check:test-typecheck holds at 27 files / 190 errors / 68 signatures, unchanged).
  • Downstream consumers are declared to CI. No export, published type or exports entry of @objectstack/service-analytics changes, so only behaviour moves. A census of examples/ at 793fb839 found no producer grouping by a structured-JSON field: 5 files declare a cube or dataset, 18 distinct dimension sources, none of them structured JSON.

Reverse verification (ablation), from the committed fix at 075a46340. It ran through scripts/ablation-replace.mjs in WRAP mode, trap-restored, under the verify lock. The door's own verdict line gained an always-true continue guard keyed on the marker __ablated_20807__. On disk: anchor 1 to 0, blob 47ce2b2acc11 to a286ca8a75ae. service-analytics was rebuilt, and ablation-dist-preflight found the marker in 2 built files (dist/index.js, dist/index.cjs).

  • Predicted direction: red. Observed: red.
  • Unit: 9 failed / 4 passed. Every refusal case and the GUARD failed; the four controls stayed green.
  • /analytics/query pin: 6 failed / 2 passed. On SQLite the cube and dataset dimensions answered 200 with 3 groups and the dry run served the statement. On PostgreSQL they answered 500 DATABASE_ERROR. The controls stayed green.
  • /analytics/dataset/query pin: 4 failed / 2 passed. meta_doc and acct_hq answered SQLite 200 and PostgreSQL 500. The controls stayed green.
  • Restore leg: blob equals HEAD (47ce2b2acc11), git diff HEAD is empty, and the whole-tree git status --porcelain is empty. After a rebuild, --absent found the marker absent from all 6 built files and the tree clean. The re-run was green: 13, 8 and 6 passed.
  • The same ablation on the first fix commit b1befe2a6, before the joined-column extension, read 8/3, 6/2 and 2/2, the same direction.

Gates

node scripts/pm/dispatch-gates.mjs --commands (no paths) at 075a46340 derived 62 commands over the 6 changed paths. That is the same list the PM derived at 793fb839. The four roster families the order names were run too: node scripts/check-changeset-fixed.mjs, pnpm check:authz-resolver, pnpm check:error-code-casing and pnpm check:filter-alias-parity. --ran reconciles: 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. All 66 commands exit 0. check:dual-build-cjs-loads and check:type-check-debt first exited 3 (PREREQUISITE NOT MET) and were re-run to exit 0 after a full turbo run build --filter='./packages/*' --filter='./packages/*/*'. Among them:

  • check:adr-0087-registration --base origin/main: [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription) accepted.
  • check:changeset-no-major, check:empty-changeset, check:doc-authoring, check:nul-bytes, check:issue-citations.
  • check:cross-package-test-inputs, check:test-source-alias, check:driver-memory-census, check:rest-log-spy-declared, check:engine-double-contract, check:query-options-erasure, check:type-check-coverage, check:type-check-debt (re-measure: none above its record).

Note: dispatch-gates flagged the tree as 6 commits behind origin/main 2d5fe76f4. None of those commits touches this diff's paths, service-analytics, the analytics routes, the engine door or STRUCTURED_JSON_TYPES, so main was not merged (the order merges only on a surface hit). The merge ref CI builds covers the joint tree.

Lint, narrowed and proven: pnpm exec eslint --no-inline-config --format json over the 5 changed .ts files at 075a46340 found 5 files, 0 errors, 0 warnings. Three facts make this narrowing a measurement:

  • The checked population comes from eslint's own config: isPathIgnored answers false for all 5.
  • The file count comes from the JSON output: 5 results.
  • Untouched files cannot change verdict: parserOptions.project and projectService are null for every file, so type-aware linting is not enabled.

Changeset

.changeset/20807-analytics-json-dimension-refused.md: @objectstack/service-analytics minor, BREAKING banner, Clause-②: no (narrowing), and exactly one ADR-0087 marker, not-required (no-migration-prescription), in the form the engine door's changeset uses. It states the refused shape, names the refusal's code, and says who is affected and what is unchanged. No export or published type changes.

Acceptance notes

  • Reported to the PM, not filed here:
    • The PostgreSQL count is a string on the native path (class a, B7). Through POST /api/v1/analytics/query on PostgreSQL 16, the text control's rows came back {"title":"x","count":"2"} while fields said {"name":"count","type":"number"}. SQLite returned 2. The registered dataset's row_count behaved the same. This is the result-typing class triage directed out of this card.
    • No authoring leg for this refusal (class c). os validate (the built CLI at 075a46340) passes a stack whose dataset declares a dimension over a json field: exit 0, "Validation passed". The runtime now refuses that dimension at query time. The same stack with a measure avg over that field is refused by measure-aggregate-field-type-refused, so the command does judge dataset members against declared types.
  • Boundaries of this door, not measured as defects:
  • Order: existence first, then type. A member naming a column the object does not have keeps the existing INVALID_FIELD ("does not have") answer, because the dimension source-field gate runs first.
  • #5930 step 3: the shared filter lowering at the analytics seams (the analytics where / preview door, the read scope) and the memory cube face's door, with the F5 / F11 output vocabulary #20810 is not addressed here: it lowers filters, and this card refuses dimensions.

Generated by Claude Code

… refused at the analytics door (red)

Pins first, before the fix. On the base they are red:
- POST /api/v1/analytics/query with a cube or dataset dimension on a json
  field answers 200 with one group per serialized document on SQLite and
  500 DATABASE_ERROR on PostgreSQL 16; /analytics/sql builds the statement.
- POST /api/v1/analytics/dataset/query answers the same two ways.
- The service door pins (both strategy faces, every structured-JSON type,
  the member as written, the one-predicate GUARD) are red; their controls
  are green.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…ON field at the analytics door

A `dimensions` entry, or a bucketed `timeDimensions` entry, whose column is
a declared structured-JSON field (the spec's STRUCTURED_JSON_TYPES, the class
the engine's groupBy door reads) is refused INVALID_FIELD / 400 in
`ensureCube`, naming the member the caller wrote, before either strategy
builds anything. NativeSQLStrategy compiled GROUP BY by hand and never
reached the engine's door: one group per serialized document on SQLite, 500
on PostgreSQL. The ObjectQL face reached the engine's door but was refused
under `groupBy[0]`, a name the caller never wrote.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…clared join names; changeset

A dataset dimension over an included relationship (`account.hq`) reached
NativeSQLStrategy unjudged: measured, SQLite 200 with one group per document
and PostgreSQL 500, like a base-object one. The door now reads the column the
way the strategy compiles it: a bare identifier on the cube's object, a dotted
identifier path on the object the cube's declared join for that path names.
A path with no declared join is a synthetic traversal and stays unjudged.

Pins: the service door and the dataset door gain the joined case and its
text control. Changeset: service-analytics minor, BREAKING, Clause-② no
(narrowing), ADR-0087 not-required (no-migration-prescription).

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 12 documentable anchor(s).

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

  • content/docs/releases/v17/17-0.mdx (via timeDimensions (literal, a string literal in assertNoStructuredJsonDimension; a string literal in groupedMembers; a string literal in param))
  • content/docs/releases/v17/17-4.mdx (via timeDimensions (literal, a string literal in assertNoStructuredJsonDimension; a string literal in groupedMembers; a string literal in param))
  • content/docs/releases/v17/17-5.mdx (via AnalyticsService (symbol, a top-level class))

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
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 10 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 0803a8b871c81892e65cbe6978a282f927787155 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 0803a8b871c81892e65cbe6978a282f927787155

⚠️ 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 0803a8b871c81892e65cbe6978a282f927787155 → 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: 075a46340e2cfc9dd57804c26318dabbfe2d366e
Local-runs: none

Inputs: card #20807 (body and all five comments: triage 5908875877, serial wait 5909886819, claim 5913045113, os-dev-report 5914264898, ACCEPT 5914351379), PR #20886 (body, six-file list, the net diff against main from the merge base 793fb83 and against main at 42d78b9), the check-runs on the head (latest run per name, read at 2026-09-30T15:38Z), and PR #20804's engine door (packages/objectql/src/group-by-structured-json-door.ts at 157baa7) as the template. Read-only throughout: git objects and the GitHub API; nothing built, run or re-run.

① Derived judgments

The diff implies one accept-set change and no public-surface change. Each judgment, named:

  1. The narrowing is right. A grouped member (a dimensions entry, or a timeDimensions entry carrying a granularity) whose resolved column is a declared field of STRUCTURED_JSON_TYPES (json, composite, repeater, record, location, address, vector) is refused INVALID_FIELD / 400 in AnalyticsService.ensureCube, on all three of its paths (inferred, augmented and declared cube), after assertDimensionFields and before assertWhereFields. That is ahead of strategy selection, so it holds for query() (/analytics/query, and every dataset selection DatasetExecutor runs through the request-scoped service, which resolves back into query()), for generateSql() (/analytics/sql) and for queryDataset (/analytics/dataset/query), on every driver. Before, SQLite answered 200 with one group per serialized document and PostgreSQL 500, because NativeSQLStrategy compiles GROUP BY itself and the engine's door never saw the query. The card's measured reach (a cube dimension over a bare json column; a dataset dimension compiling to the same) is closed.
  2. One predicate, as triage directed: right. The class is @objectstack/spec/data's STRUCTURED_JSON_TYPES (field-value.zod.ts, seven members), imported; the engine door imports the same constant. Nothing under packages/objectql or packages/spec moves. The engine door's function is not called because it judges groupBy[i] positions, which is the wrong name for a cube member. The GUARD pin holds the judged set equal to the constant over every FieldType.
  3. Member resolution mirrors the strategies: right. The service supplies the column through declaredMemberEntry(cube, member, 'dimension'), the existence gate's own lookup (cube.dimensions only; the cube.member, tail and flattened spellings), and the member itself when the cube declares none, which is what NativeSQLStrategy.lookupMember and resolveDimensionSql resolve as well. A dotted path is judged on cube.joins[path with its dots as __].name, the alias the dataset compiler's joinAlias registers and the strategy's joinAlias reads. The joined case (account.hq) is a real extension of the card's class, measured by the dev on the base the same two ways, and is named on the joined object.
  4. The envelope is right, and it is Clause-② no. invalidMemberError (code, status 400, member, param, cube) plus field and object. Every key already exists on this door's refusal family (invalidMemberError's five; field and object on the dimension source-field gate's error), so no new key reaches a published payload. Inherited, not this PR's: the thrown error carries status and not httpStatus (the engine door sets both under ADR-0112 D5); the wire body carries httpStatus 400 and the runtime pin asserts it there.
  5. Public surface unchanged: right. @objectstack/service-analytics exports . only and src/index.ts is untouched; the new module is internal. No spec key, export or stored shape moves.
  6. Order: right. Existence first (assertDimensionFields, "does not have"), then type; pinned.
  7. The stand-downs are right as declared, with one live residual (③ item 6). No sourceFieldMeta; a cube whose sql is not a bare object name; an expression sql; a dotted path the cube declares no join for; every other type. The first three are the sibling gates' tiering (analytics: a measure naming a missing field 500s with SQLITE_ERROR instead of a 400 naming the field #4437, [17.0-rc2验收] analytics: 不存在的 dimension 500(泄漏 SQL / SQLITE_ERROR)而不是 400 指名字段 —— #4437 只给 measure 加了闸门,dimension 侧对称缺口仍在 #5520, analytics: where 里点名不存在的字段仍然一路到驱动 —— #4437(measure)/ #5520(dimension)之后,filter 面是同一个缺陷剩下的第三个 param #5669). The fourth is where the card's class can still reach a driver on the cube face.
  8. Time dimensions: right. Only a bucketed timeDimensions entry groups; an unbucketed one bounds a range and belongs to the filter class (#5930 step 3: the shared filter lowering at the analytics seams (the analytics where / preview door, the read scope) and the memory cube face's door, with the F5 / F11 output vocabulary #20810's lowering), not this card. A grouped time member listed under dimensions is judged there whatever the granularity default does; pinned (stamped).
  9. Pins: right for what they claim. Three new files. The unit file covers both strategy faces, every member of the class, the envelope, zero raw SQL and zero aggregates, the dataset and joined cases, the dry run, and the GUARD. The two HTTP pins run the shipped composition (AnalyticsServicePlugin over a real ObjectQL, both auto-bridges live) through the real routes: runtime/src/domains/analytics.ts serves /analytics/query and /sql, rest/src/rest-server.ts serves /analytics/dataset/query, so the cube-face pin's home in packages/runtime/src, beside three sibling analytics-*.test.ts files, is the measured one and the claim's packages/rest/src naming for it was wrong. SQLite always; PostgreSQL a named skip without OS_TEST_POSTGRES_URL. The wording, the route text and the count of reads are asserted on the wire body.
  10. Gate verdicts on the head, latest run per name, 32 names, at the reading above: 29 success; 3 skipped by the path filter (Console Pin Gate, Build Docs, Packed-tarball smoke); Test Core (4/6) was still in progress. Check Changeset (which runs check-changeset-no-major and check-adr-0087-registration against the merge base), Lint & Repo Gates, the four Type Check jobs, Governed Surface Queue Guard, Temporal Conformance and Test Core 1, 2, 3, 5 and 6 are green. No governed path is in the file list.

② Semver level

  • The diff publishes behaviour only, from one released package, @objectstack/service-analytics (17.5.0): a refusal where SQLite served a result. That is an accept-set narrowing, BREAKING, and the PR body's line 2 reads Clause-②: no (narrowing), which is the measured grammar: no new key on a published payload (① item 4) and the set only shrinks. It is the closed pair's BREAKING arm, not yes, so the bump is owed to the breaking change itself.
  • .changeset/20807-analytics-json-dimension-refused.md: @objectstack/service-analytics: minor, the ! in the title, the BREAKING banner, the same Clause-② line, and exactly one ADR-0087 marker, not-required (no-migration-prescription). Under the launch-window convention (check-changeset-no-major: a breaking change ships as minor until GA, and the carriers are the banner and the ADR-0087 disposition) minor is the right level and both carriers are present. skip-changeset would have been wrong: the package publishes.
  • The disposition is right on its facts: nothing authorable, exported or stored moves, so there is no FROM and TO to prescribe and the body carries none; the package is not private (unpublished closed); no ADR-0087 id covers a grouping target (already-registered closed); the change is runtime behaviour, not a declaration (runtime-interface-only and type-surface-only closed). The body states what an author sees, why, who is affected and what is unchanged, in the form of the engine door's changeset (20783-groupby-structured-json-refused.md). Check Changeset and Lint & Repo Gates are the gate verdicts; both success.

③ Boundary flags

Every dev flag (os-dev-report 5914264898) and every open question, answered or escalated:

  1. Deviation: the /analytics/query pin lives in packages/runtime/src, not packages/rest/src. Answered, right (① item 9). A new pin file only; production code stays in service-analytics.
  2. Deviation: PR assignee not set (relay answered HTTP 0). Closed by the owning seat (ACCEPT 5914351379); the PR carries os-justin.
  3. Deviation: main not merged. Answered. From the merge base 793fb83 to main at 42d78b9, nothing under packages/services/service-analytics, packages/spec/src/data/field-value.zod.ts, the engine door, runtime/src/domains, dispatcher-plugin.ts, rest-server.ts or the six changed paths moved. packages/objectql/src/engine.ts did move ([Decision] v18:查询能否直接按关联记录的字段筛选(例:「客户行业 = 科技」的商机) #20802's nested-relation filter lowering, ca5408c) after the CI merge ref's base 0803a8b; the HTTP pins traverse a real engine but the door reads nothing from it, and the queue's merge-group build covers the joint tree. Not a re-review trigger.
  4. Deviation: the private PostgreSQL data directory sat under /tmp. Local hygiene; started, stopped and deleted by the run. Answered.
  5. B5: no CI step provisions a live PostgreSQL for these pins. Confirmed from ci.yml: OS_TEST_POSTGRES_URL is set for three steps only (the driver-sql suite, metadata-protocol's migration statements, runtime's cascade-delete matrix). The PostgreSQL cells of all three pins are red-capable named skips in CI; the PostgreSQL readings (4 and 3 green, plus the red-first and ablation cells) are the dev's local PostgreSQL 16.13 run and were not re-run here. Same shape as packages/rest/src/data-group-by-json-door.test.ts (fix(objectql)!: a groupBy on a structured-JSON field is refused INVALID_FIELD / 400 at the engine aggregate door, on every driver (#20783) #20804). Accepted as declared.
  6. ESCALATED: the "dotted path with no declared join" stand-down is a live residual of the card's class on the cube face. By reading (not measured by the dev; not measurable read-only here): on a hand-authored cube that declares no joins, a dotted dimension (an authored sql: 'account.hq', or a caller's dimensions: ['account.hq']) is resolved by NativeSQLStrategy.lookupMember's synthetic fallback and qualifyAndRegisterJoin's cube.joins[alias]?.name ?? alias fallback into LEFT JOIN "account" ON base."account" = "account"."id" and GROUP BY "account"."hq". The door's columnOf returns null there and does not judge, although the strategy's landing object is deterministic (the alias itself) and sourceFieldMeta(alias, column) would answer or stand down. On the dataset route this is closed twice (compileDataset's assertDeclared for the dataset's own members; the D-C join allowlist for a caller's selection, DATASET_INVALID / 400). On the cube route the allowlist runs only when the host wires getAllowedRelationships, which AnalyticsServicePlugin passes through unset by default; so where hq is structured JSON on the object the alias names, SQLite still groups per document and PostgreSQL still answers 500. The changeset's "Unchanged" paragraph and the module header declare exactly this exclusion, so the claim is as narrow as the enforcement, and the sibling gates' own answer is that a dotted reference "belongs to the join allowlist"; but that allowlist is unenforced for authored cubes by default, and the direction's "never a 500" does not hold on that one path. Owed: a finding card (class a, by reading, unmeasured) on the cube face's synthetic-join fallback with the join allowlist unenforced for hand-authored cubes, serial after this PR, filed by the owning seat; or a ruling that the legacy same-name fallback is outside the card. It is outside this PR's declared accept set, so it does not fail the record.
  7. open_questions: none declared.
  8. out_of_scope_findings[0], the string count on PostgreSQL: filed as [finding] analytics: on PostgreSQL the native-SQL path answers a measure the response declares number as a string (count: "2"), where SQLite answers 2 — the class #20335 closed at the engine door #20889 (open, finding). Answered.
  9. out_of_scope_findings[1], os validate passes what the runtime refuses: filed as [finding] os validate passes a dataset dimension over a structured-JSON field that the analytics runtime refuses 400 INVALID_FIELD — only the measure has an authoring-time leg #20890 (open, finding, serial after this PR). Answered.
  10. out_of_scope_findings[2], the unjudged boundaries with no carrier: a timeDimensions entry with no granularity is a window and belongs to the filter class (① item 8); a host without sourceFieldMeta is the sibling gates' stand-down; the draft preview's evaluateAnalyticsQueryOverRows groups in memory over seed rows, builds no SQL and cannot answer this class's 500, though it does not pass ensureCube; MemoryAnalyticsService is [finding] driver-memory analytics: MemoryAnalyticsService drops an array (FilterArray) where and answers every row, where the object spelling filters and the engine refuses 400 #20859's position. Answered. The fifth, the dotted path with no declared join, is item 6.
  11. Docs Drift Check (PR comment 5914232572): three release-owned pages name a touched anchor; they are read-only by rule and no docs edit is owed here. Answered.
  12. Landing condition: this record is the review the changeset prose owed; landing still waits for Test Core (4/6) to conclude green and stays the owning seat's act (a draft onto main; no governed surface).

Implemented-by: claude/issue-20807-analytics-json-dimension
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS


Generated by Claude Code

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/xl tests tooling

Projects

None yet

2 participants