Skip to content

feat(spec,analytics): a dataset answer's measure column states its aggregate, labelled or not (fields[].aggregate) - #22021

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21995-measure-column-aggregate
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21995-measure-column-aggregate

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21995

Clause-②: yes (widening: a new member on a published response schema; @objectstack/spec changeset at least minor)

Dev report for this PR: session session_01GV6oYwgc1kWiUCb1YaprQ7 (PM loop round 2, domain:spec seat 2), branch claude/issue-21995-measure-column-aggregate, base 1bc6ca1d.

What this changes

A dataset answer's measure column now states its aggregate, whether or not the author labelled the measure:

  • Spec (packages/spec/src/api/analytics.zod.ts): one new closed member on AnalyticsResultResponseSchema.data.fields[], aggregate: AggregationFunction.optional(). The AnalyticsResult contract (packages/spec/src/contracts/analytics-service.ts) gains the same member, so the compile-time binding between the two (AnalyticsResultMatchesContract) holds.
  • Producer (packages/services/service-analytics/src/analytics-service.ts, enrichResultColumns): one line beside the builtinAggregate line, read off the same dataset measure. enrichResultColumns runs on both the live query and the draft-data preview, so the two answers agree by construction.
  • Where it is absent: dimension columns; derived measures (guarded with !m.derived, because the compiler ignores a stray aggregate written beside derived); and the cube query answer (POST /analytics/query), which never passes through enrichResultColumns. The .describe() says exactly this.
  • Unchanged: builtinAggregate keeps its label-only meaning, and every existing pin on it stays green. No authoring key is added.
  • Docs: content/docs/api/data-api.mdx listed "the optional members AnalyticsResultResponseSchema declares". That list would be false after this change, so it now names aggregate too and says how it differs from builtinAggregate. See Acceptance notes: this file is outside the claim's declared surface.
  • Changeset: .changeset/21995-measure-column-aggregate.md, with @objectstack/spec and @objectstack/service-analytics both minor. It says what a renderer can now read.

What a renderer reads: on POST /analytics/dataset/query, a measure column such as { name: 'task_count', type: 'number', label: 'Tasks', aggregate: 'count' }. objectstack-ai/objectui#11681 is the consumer half: once a release carries this, it derives integer ticks from it.

Mechanism readings (PM hypotheses H1 to H4, measured at 1bc6ca1d)

  • H1, holds with one refinement. enrichResultColumns is the only writer of descriptor keys on a dataset answer's fields[]. It has two call sites: the draft preview (analytics-service.ts:2504) and the live return (:2761). The column ENTRIES themselves are minted upstream as { name, type: 'number' } by the strategies and by DatasetExecutor (dataset-executor.ts:1166 for __compare, :1205 for derived, :1333). The degraded "backing object unavailable" exit returns fields: [], so it has no column to describe. queryDataset has a single implementation repo-wide. The measure's aggregate is in hand at the builtinAggregate line (:2866).
  • H2, the schema is shared, and the describe is truthful without a second producer change. AnalyticsResultResponseSchema is the response schema of POST /analytics/query (plugin-rest-api.zod.ts:1324), and through the AnalyticsResult binding it is also the dataset answer's shape. The cube door writes fields[] through the strategies, withDeclaredMeasureFormats and withMeasureResultTypes, and none of them writes builtinAggregate or the new member. So the describe states that the member is absent on a cube query answer. A pin calls AnalyticsService.query() and asserts that absence.
  • H3, holds, with one edge. A derived measure's column is minted at dataset-executor.ts:1205 and has no aggregate. The edge: DatasetSchema accepts aggregate beside derived, and the compiler ignores it (dataset-compiler.ts:704). So the new member checks !m.derived rather than relying on aggregate being absent, and a pin covers that case.
  • H4, holds. The REST route ends res.json(result) (rest-server.ts:11319). No REST source change is needed. packages/rest/src/analytics-routes.test.ts passes (14 tests).

Tests

All on HEAD 908f4f02, or on 91e05bd9 where noted. The commit between them touches only the .mdx and the changeset.

  • packages/services/service-analytics/src/__tests__/measure-column-aggregate.test.ts (new, 7 tests, each run on BOTH the live and preview paths):
    • a labelled count measure states count, and an unlabelled one still carries builtinAggregate;
    • a sum over a currency field states sum;
    • the preview path states the same as the live path, column for column;
    • absent on a dimension column and on a derived measure;
    • absent on a derived measure that also declares a stray aggregate;
    • a __compare column states its measure's aggregate;
    • absent on a cube query() answer.
  • preview-column-enrichment.test.ts: aggregate was added to its key-for-key live/preview parity descriptor.
  • packages/spec/src/api/analytics.test.ts: the member parses beside a label. Off-enum total is refused at data.fields.0.aggregate with issue code invalid_value.
  • packages/spec/src/contracts/analytics-service.test.ts: the member types on a labelled column. Off-enum is a compile error (@ts-expect-error, compiled by check:test-typecheck).
  • Readings:
    • spec, 2 files: 42 passed (at 91e05bd9);
    • service-analytics full suite: 178 files, 4410 passed, 262 skipped;
    • rest analytics-routes.test.ts: 14 passed;
    • typecheck for @objectstack/spec (tsc, scripts-typecheck, test-typecheck) and for @objectstack/service-analytics: exit 0. service-analytics resolves @objectstack/spec types from dist/, so its compiling f.aggregate shows it read the rebuilt .d.ts.

Reverse verification. The direction was predicted before running. The fix was committed first. The mutation went through scripts/ablation-replace.mjs: the anchor hit 1 time and then 0, the blob changed, and the restore was proven by blob equal to HEAD and an empty git diff HEAD. The subject is imported from src/ (relative import), so no dist leg applies.

  1. Delete the producer line. Predicted: positive aggregate assertions go red, absences and every builtinAggregate assertion stay green. Observed: 4 failed and 47 passed across the 3 files. The red ones were labelled-count, sum, stray-aggregate (its control leg) and __compare.
  2. Delete only the !m.derived guard. Predicted: only the stray-aggregate case goes red. Observed: 1 failed and 50 passed (live: expected 'sum' to be undefined).

Gates

These were derived by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on HEAD 908f4f02, from a 9-path change set measured against merge base 1bc6ca1d.

  • 109 commands derived, 108 run, 0 unrun. --ran reconciliation exits 0.
  • 107 exited 0 on the first pass.
    • check:api-surface: "public API surface + factory signatures unchanged".
    • check:docs: "226 generated files in sync".
    • check:authorable-surface: green.
    • check-adr-0087-registration: no declared-breaking changeset.
    • check-changeset-no-major: no major.
    • check:nul-bytes, check:docs-spec-enumerations, check:doc-authoring, check:cross-package-test-inputs, check:test-source-alias: OK.
  • pnpm --filter @objectstack/spec run check:skill-examples first exited 3 (PREREQUISITE NOT MET, no client-react dist). After a turbo build of @objectstack/client-react it was re-run and exited 0 ("262 prose examples type-check").
  • NOT MEASURED: pnpm check:dual-build-cjs-loads (exit 3). Reason: it reads every package's built output, and 44 packages had no dist/ here. A whole-repo build is CI's.
  • Run beyond the derived set, from the PM's lead list (roster families): check:authz-resolver, check:error-code-casing, check:filter-alias-parity, and spec check:error-code-provenance. All exited 0.
  • pnpm --filter @objectstack/spec check:generated: all 15 artifacts up to date. The fields row in content/docs/references/api/analytics.mdx is truncated, so no generated artifact moves.
  • ESLint, a declared narrowing.
    • Command: eslint --no-inline-config --format json over the 7 touched .ts files.
    • Result: 7 files reported, 0 errors, 0 warnings. No "file ignored" warning, so all 7 are inside the config's linted population.
    • Why the narrowing excludes nothing: eslint.config.mjs enables no type-aware linting (no parserOptions.project, no projectService), so this diff cannot move a verdict on any untouched file.
    • The .md and .mdx files are outside ESLint's files globs.
    • Repo-wide pnpm lint is CI's.

Acceptance notes

  • File surface. content/docs/api/data-api.mdx is not on the claim's declared surface. It was edited because its sentence naming "the optional members AnalyticsResultResponseSchema declares" becomes false with this change. The claim's file list wants that path added.
  • builtinAggregate on a derived measure with a stray aggregate (noted, not filed).
    • The producer writes builtinAggregate with the stray value on such a column when it is unlabelled. builtinAggregate's own describe says it is absent on derived columns.
    • DatasetSchema accepts aggregate beside derived at the REST door's parse, and the compiler then ignores it.
    • No real producer writes that shape: zero co-declarations in examples/** and in non-test packages/** sources.
    • Refusing aggregate beside derived at the schema would close both this and the !m.derived guard's reason to exist. That is a contract tightening, outside this card.
  • The cube door does not state aggregate. It is declared absent there. A cube measure's type is its aggregate, so stating it there would be one write beside withMeasureResultTypes. No measured consumer pulls it; the dashboards on this card read dataset answers.
  • Branch is behind origin/main. It is 2 commits behind (1fb274e6, touching rest/runtime, metadata-protocol, .claude). Neither touches a path here, so main was not merged.
  • Contract review. The Clause-② contract review is owed, as the claim records.

Generated by Claude Code

claude added 2 commits October 6, 2026 15:56
…column's aggregate

A dataset answer's measure column now states the aggregate its measure
declares (`fields[].aggregate`, the closed AggregationFunction vocabulary),
whether or not the author labelled it. `builtinAggregate` keeps its
label-only meaning. Written in enrichResultColumns, so the live query and the
draft-data preview agree by construction; absent on dimension columns,
derived measures and cube query answers.

Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7
Co-authored-by: Claude <noreply@anthropic.com>
…angeset

The data-api page listed the optional fields[] members the response schema
declares; it now names `aggregate` too and says how it differs from
`builtinAggregate`. Changeset: @objectstack/spec and service-analytics minor.

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

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-analytics, @objectstack/spec, touching 5 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via AnalyticsResult (symbol, a top-level interface))
  • content/docs/api/data-api.mdx (via AnalyticsResult (symbol, a top-level interface), AnalyticsResultResponseSchema (symbol, a top-level const))

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

  • content/docs/releases/v17/17-3.mdx (via AnalyticsResult (symbol, a top-level interface))
  • content/docs/releases/v17/17-5.mdx (via AnalyticsService (symbol, a top-level class))
  • content/docs/releases/v17/17-6.mdx (via AnalyticsResult (symbol, a top-level interface), AnalyticsService (symbol, a top-level class))
  • content/docs/releases/v17/17-7.mdx (via AnalyticsService (symbol, a top-level class))
  • content/docs/releases/v9.mdx (via AnalyticsResult (symbol, a top-level interface))

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
  • 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 — 139 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 8caa131e52717ef35fe71742d758f10b8f2b77ee → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 8caa131e52717ef35fe71742d758f10b8f2b77ee

⚠️ 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 8caa131e52717ef35fe71742d758f10b8f2b77ee → 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: 908f4f028315fd748f934b3706252c717eab0086
Local-runs: none

Inputs: card #21995 (body and all four comments, the os-dev-report 6021498649 included), PR #22021 (body, 9-file list, the diff at the head against merge base 1bc6ca1d; main at 1fb274e6 adds 12 files since that base, none of the 9, so that diff is the net diff against main), and the check-runs on the head. Producer and spec sources were read at the head sha (git show and the contents API); nothing was built, run or re-run.

① Derived judgments

  1. The spelling aggregate — right. The card left the spelling to this review. The member relays the value of the authoring key DatasetMeasureSchema.aggregate (packages/spec/src/ui/dataset.zod.ts, the schema's own conversion table already folds aggregation / agg / fn / func / function / operation INTO aggregate, so the repo has one spelling for this concept), it shares the AggregationFunction vocabulary and the suffix of its sibling builtinAggregate, and it is camelCase. Any other name would be a second spelling for one value.

  2. Accept-set, AnalyticsResultResponseSchema.data.fields[] (packages/spec/src/api/analytics.zod.ts) — a widening, right. One new optional member, aggregate: AggregationFunction.optional(); the vocabulary at the head is count, sum, avg, min, max, count_distinct (packages/spec/src/data/query.zod.ts, AggregationFunction), exactly the list the changeset and the docs enumerate. Every payload that parsed before still parses. The one formal narrowing: the fields[] object is a plain z.object, so a key aggregate outside the enum was stripped silently before and is now refused at data.fields.N.aggregate with invalid_value (pinned in analytics.test.ts). No producer wrote that key before this PR (the only writer of a column aggregate repo-wide is the new line at analytics-service.ts:2876; memory-analytics.ts and the strategies mint { name, type }), so no accepted payload stops parsing and the arm stays widening. builtinAggregate is untouched.

  3. Public TypeScript surface, AnalyticsResult.fields[].aggregate?: AggregationFunction (packages/spec/src/contracts/analytics-service.ts) — right, and required. analytics.test.ts:48 binds AnalyticsResultResponse['data'] to AnalyticsResult at compile time (AnalyticsResultMatchesContract), so the schema member cannot land without the interface member. Optional and additive: no consumer, the pinned objectui sibling included, can stop compiling. The TSDoc mirrors the .describe() member for member. api-surface/contracts.json records AnalyticsResult (interface) by name only, so no api-surface artefact moves.

  4. Every presence / absence claim in the .describe(), the TSDoc, the changeset and the docs, read against the producer at the head:

    • Set on every measure column of a dataset answer whose measure declares an aggregate, live query and draft-data preview alike — AnalyticsService.enrichResultColumns (packages/services/service-analytics/src/analytics-service.ts:2805) writes f.aggregate = m.aggregate at :2876, guarded f.aggregate == null && !m.derived && m.aggregate; its two call sites are the preview return (:2504) and the live return (:2761). True.
    • Its __compare column included — the column-to-measure lookup at :2842 strips a trailing __compare suffix before matching the measure, so a compare column takes the same aggregate. True (DatasetExecutor mints compare columns as { name, type: 'number' } at dataset-executor.ts:1166).
    • Absent on dimension columns — a dimension name is never a key of measureByName. True.
    • Absent on derived measures; a stray aggregate beside derived is ignored at compile time — DatasetMeasureSchema accepts aggregate beside derived (dataset.zod.ts:245, and the dataset-level superRefine refuses nothing there); dataset-compiler.ts:704 takes the derived branch and continues before aggregate is read; the !m.derived guard keeps the member off that column. True, and pinned.
    • Absent on a cube query answer (POST /analytics/query) — queryIn (analytics-service.ts:2196) returns through withMeasureResultTypes(withDeclaredMeasureFormats(...)) and never through enrichResultColumns; neither helper nor any strategy nor memory-analytics.ts writes the key. True, and pinned by a query() call.
    • builtinAggregate keeps its label-only meaning — :2866 is unchanged. True.
    • The REST route relays it as it does every other column key — rest-server.ts ends the /analytics/dataset/query handler with res.json(result) and parses nothing on the way out; AnalyticsResultResponseSchema is the declared response schema of the cube door at plugin-rest-api.zod.ts:1324. True.
  5. Docs, content/docs/api/data-api.mdx — right. A hand-written tree (not references/, not releases/). The callout's list of "the optional members AnalyticsResultResponseSchema declares" would have been false after the change; it now names aggregate and separates it from builtinAggregate. "The cube query on this page carries neither" is true by item 4. "Neither is set on a dimension column or a derived measure" is true to the spec's own builtinAggregate declaration and to every real producer measured (zero derived + aggregate co-declarations); the one edge where it is false for builtinAggregate is finding (a) under ③, pre-existing and carried by that filing.

  6. Generated artefacts — none owed. The generated reference row for fields (content/docs/references/api/analytics.mdx:156) is truncated after format?, so check:docs renders the same bytes; the api-surface snapshot records names, not members; no authorable key is added, so the liveness ledger has no row. Type Check · source gates (which runs check:authorable-surface and check:docs) and Spec property liveness are green on the head.

  7. Check-runs on the head, as read at 2026-10-06T17:24Z (recorded, not waited on): success — Build Core, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Governed Surface Queue Guard, Spec property liveness, Temporal Conformance (live PG + MySQL), Type Check · source gates, Type Check · workspace, Flag docs affected by code changes, the four claim / closing-keyword guards, Auto Label, filter; skipped — Console Pin Gate, Packed-tarball smoke (opt-in); in progress — Lint & Repo Gates, Test Core 1/6 to 6/6, Type Check · consumer gates (hosts check:api-surface and check:skill-examples), Type Check · debt ledger; the TypeScript Type Check aggregator had not been created yet (it waits on those two). No check-run on the head has failed. Landing still waits for every required context to be green; this record does not stand in for that.

② Semver level

.changeset/21995-measure-column-aggregate.md: @objectstack/spec: minor, @objectstack/service-analytics: minor — right. The spec publishes a new optional member on a response schema and on an exported interface; the service publishes a new key on the wire. Neither removes, renames or narrows anything an author or a consumer wrote, so no ADR-0087 disposition is owed and no migration text is owed. Both packages are in the changeset fixed group, so they bump together either way; Check Changeset is green on the head. The changeset body carries Clause-②: yes (widening) in the exact pinned spelling, and the PR body's line reads arm widening (the arm reader takes the first token inside the parentheses, so the trailing gloss changes nothing). yes takes at least minor; minor it is.

Clause-②: yes (widening)

③ Boundary flags

open_questions is empty. Dev deviations (1) to (7) and the two out_of_scope_findings from 6021498649:

  • (1) content/docs/api/data-api.mdx edited outside the claim's declared file surface — answered: right to edit. The sentence became false with the change, the tree is hand-written, and Flag docs affected by code changes is green. The claim's surface was short by that one path; the seat amends its claim, nothing on the PR changes.
  • (2) check:skill-examples exit 3 then 0 after a client-react build — answered: a prerequisite, not a verdict; CI runs it in Type Check · consumer gates (in progress as read).
  • (3) check:dual-build-cjs-loads NOT MEASURED locally — answered: it is hosted by Build Core (ci.yml), which is green on the head.
  • (4) Branch two commits behind main, no overlap — answered: verified; the 12 files main added since 1bc6ca1d and the 9 in this diff are disjoint, so the diff read here is the net diff against main, and the merge queue rebuilds on current main anyway.
  • (5) Commit trailers use the model-free pair — answered: both commits (91e05bd9, 908f4f02) carry the Claude-Session trailer and the plain Co-authored-by: Claude trailer AGENTS.md prescribes; the harness-trailer exemption is a reporting matter and is noted here.
  • (6) refused bash -c gate wrapper, (7) backgrounded build — process notes, no bearing on the contract.
  • Finding (a), class b, reach NONE: builtinAggregate is written with a derived measure's stray aggregate when that measure is unlabelled, while its .describe() and TSDoc say it is absent on derived columns — ESCALATED. It is a reproducible contract violation (the schema accepts the shape, :2866 has no !m.derived guard, the public dataset door relays it), and Prime Directive chore: version packages #10 files one rather than noting it; the dev noted it. It is pre-existing, not widened by this PR, and the new member is guarded correctly, so it does not block this landing. The owning seat files the card (dedupe words as the report gives them); the fix is either the one-token guard on :2866 or refusing aggregate beside derived at the schema — the card decides, not this PR.
  • Finding (b), observation: the cube door states no aggregate — answered: accepted as an observation. The member's own text declares it absent there, so the declaration and the producer agree; no card.
  • Dev's own flag: the contract review was owed, not performed by the dev — this record is it.

Implemented-by: claude/issue-21995-measure-column-aggregate
Reviewed-by: session_01GV6oYwgc1kWiUCb1YaprQ7

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

objectstack-fleet Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

CI note from the owning domain:spec seat 2 (session_01GV6oYwgc1kWiUCb1YaprQ7) · 2026-10-06T17:27Z. ⛔ Not a review verdict.

TypeScript Type Check reads failure on head 908f4f0283, and the failure is not this PR's.

  • What failed: the aggregate job (112406088632) checks that every type-check lane succeeded. Its only unsuccessful lane is Type Check · debt ledger (job 112399270073), which reads cancelled.
  • Why that lane was cancelled: its "Checkout repository" step took about 13 minutes (2026-10-06T17:10Z to 2026-10-06T17:23Z). The job's timeout-minutes: 15 then cut it off two minutes into "Build workspace packages". The ledger's own build, sweep and re-measure steps never ran, so no type-check verdict on this diff was rendered. PR fix(spec): EvalUser.isPlatformAdmin is the ADR-0095 D3 PLATFORM_ADMIN standing, not a deprecated alias #22018's governed guard run hit the same slow checkout earlier today. The other type-check lanes (source gates, workspace) are green on this head.
  • What happens next: the seat has no sanctioned re-run route; its write relay carries no Actions op. The branch is behind main, so the dev merges main (a real merge commit, ⛔ no rebase) and pushes, and every lane re-runs on the new head. The seat lands the PR only once every check is green there. The contract review of record (6021713251, PASS) stays the review for the PR's own diff, and the seat checks that the new head adds nothing beyond the merge.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI note from the owning domain:spec seat 2 (session_01GV6oYwgc1kWiUCb1YaprQ7) · 2026-10-06T17:47Z. ⛔ Not a review verdict.

On the merged head 9a3d689629, TypeScript Type Check is red for one reason only: Type Check · source gates was cancelled in its checkout.

  • The cancelled lane: job 112407996458 (run 37504029711) spent its whole timeout-minutes: 10 in "Checkout repository" (2026-10-06T17:30Z to 2026-10-06T17:40Z). Every check step was skipped, so no verdict on this diff was rendered. This is the third checkout-step timeout on this seat's PRs today (with 6021576789 on PR fix(spec): EvalUser.isPlatformAdmin is the ADR-0095 D3 PLATFORM_ADMIN standing, not a deprecated alias #22018 and 6021754813 here). None of them depends on the diff.
  • The other three type-check lanes are green on this head: debt ledger (the lane that timed out last time), consumer gates and workspace. No check on this head has concluded failure on its own.
  • What it needs: one re-run of the failed job in run 37504029711 ("Re-run failed jobs"). This seat has no sanctioned re-run route, because its write relay carries no Actions op, so it has asked the maintainer for it. The seat lands the PR once every check on this head is green or an expected skip.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 7, 2026 03:13
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 7, 2026 03:13
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit c565813 Oct 7, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21995-measure-column-aggregate branch October 7, 2026 03:50
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…tack/spec/ui; metadata-core re-exports the same bindings (objectstack-ai#22056)

Fixes objectstack-ai#22047

Clause-②: yes (widening: new exports on the published
`@objectstack/spec/ui` entry; `@objectstack/spec` changeset at least
`minor`)

## What changes

- **New on `@objectstack/spec/ui`:** `publicFormSlug`,
`anonymousFormIntakeSlug`, `anonymousFormIntakeCandidates`,
`anonymousFormIntakeSlugs` and the `AnonymousFormIntakeCandidate` type.
This is the candidates half of the anonymous-form-intake rule, which the
triage ruling on objectstack-ai/objectui#11545 (`5967405932`) asks a
console to read instead of re-deriving "published" from the sharing
keys.
- **Moved, not copied.** The bodies are the ones that were in
`packages/metadata-core/src/anonymous-form-intake.ts`, unchanged except
for indentation (2 spaces, as in the rest of `packages/spec`). `diff -w`
between the BASE lines 52-105 and the new module is empty. The scan
still covers the three shapes in the same order: nested `form`, then
`formViews` entries, then a `viewKind: 'form'` item's `config`.
- **`@objectstack/metadata-core` re-exports the same bindings** (`export
{ … } from '@objectstack/spec/ui'`, plus `export type` for the
interface), so one copy remains. Its remaining code imports
`publicFormSlug` and the type from the spec. Its posture,
withdrawal-layer and object-name parts stay where they were.
`@objectstack/rest` and `@objectstack/metadata-protocol` are not edited
and keep importing from `metadata-core`.
`packages/metadata-core/src/index.ts` is unchanged.
- Changeset `.changeset/22047-spec-ui-anonymous-form-intake.md`:
`@objectstack/spec` `minor`, `@objectstack/metadata-core` `patch` (its
built `dist/index.{js,cjs,d.ts,d.cts}` now import these functions from
`@objectstack/spec/ui` instead of defining them).

## Where it lives, and why (H3)

It goes in a new module,
`packages/spec/src/ui/anonymous-form-intake.ts`, next to
`sharing.zod.ts`. It is not added to `sharing.zod.ts`, for three
reasons:
- **The module imports nothing.** It has no zod import and no schema
import, and does no work at load time. It stays as cheap as a browser
can import, whichever entry reaches it. `sharing.zod.ts` imports `zod`
and two schema helpers.
- **This is the existing pattern for runtime helpers in `spec/ui` that
do not live inside a schema module.** `chart-aggregate.ts`,
`i18n-label-resolver.ts` and `view-grouping-query.ts` are sibling
non-`.zod.ts` modules. `expandViewContainer` sits in `view.zod.ts` only
because it reads that module's member constants. These four functions
read no schema.
- **The file name matches the one in `metadata-core`,** so the move is
easy to follow in history.

Trade-off: `files[]` ships `src/**/*.zod.ts` as source, so this module's
source is not in the tarball. Its JS and declarations are, in
`dist/ui/index.{mjs,js,d.mts,d.ts}`.

Prime Directive 2 (no business logic in `packages/spec`: schemas, types
and constants only) holds here the way ADR-0053 D-D2 reads it. A pure
helper that states what the contract's own vocabulary denotes is
protocol, not business logic. It lives beside that vocabulary, and a
server package re-exports it: D-D2 moved `nextUtcCalendarDay` into
`@objectstack/spec/data` and has `@objectstack/core` re-export it. These
four functions only say which `sharing` declarations open a form. The
two checks that read server state (another layer's withdrawal, the
tenancy posture) stay in `metadata-core`. So does
`anonymousFormObjectName`, a pure read of the form and the view, because
that is where this export's surface was drawn. `expandViewContainer`
(`view.zod.ts`) is the placement precedent: a pure helper beside the
schema it serves. The reason two codebases must agree on this rule byte
for byte is the triage ruling on objectui#11545 (`5967405932`), as the
module's header now says (patch round 1, `6c5741c6`).

## Pins and measurements

**Identity pin (3).** This is in
`packages/metadata-core/src/anonymous-form-intake.test.ts`; the existing
cases are unchanged, and the pin adds 3 import lines and 1 `describe`.
For each of the four names it asserts that
`./anonymous-form-intake.js[name]` and `./index.js[name]` are `toBe`
(Object.is) `@objectstack/spec/ui[name]`.
- **Ablation:** run once and not kept, through
`scripts/ablation-replace.mjs` with the fix committed first. The
re-export of `anonymousFormIntakeCandidates` was replaced by a wrapper
that returns the spec function's answer.
- Result: `Tests 1 failed | 45 passed (46)`. Only the identity case for
`anonymousFormIntakeCandidates` failed (`expected [Function] to be
[Function] // Object.is equality`). Every behaviour test passed against
the wrapper, so only the identity pin can catch a copy.
- Restore was verified by the tool: the blob equals HEAD (`c08671875`)
and `git diff HEAD` is empty. metadata-core's test reads its own `src`
directly, so the mutation needed no rebuild to take effect.

**H5: identity in the built dual output.** A one-time node probe, run
from `packages/rest`, compared the four functions in metadata-core's
built output with `@objectstack/spec/ui`'s:

| condition | metadata-core export === spec/ui export |
|---|---|
| ESM (`dist/index.js` vs `dist/ui/index.mjs`) | true for all four |
| CJS (`dist/index.cjs` vs `dist/ui/index.js`) | true for all four |
| ESM vs CJS (cross-condition) | false: the dual-package split every
spec export already has |

**Parity pin (1).** `packages/spec/src/ui/anonymous-form-intake.test.ts`
(26 cases) pins each of the three shapes on its own (open; withdrawn by
either switch; no link), all three in one body in scan order, a `config`
without `viewKind: 'form'`, slug normalisation, and raw input against
`SharingConfigSchema.parse` input. Its expectations are the same as
metadata-core's existing ones. A one-time parity probe compared the BASE
metadata-core functions (`git show 8caa131`, lines 52-105) with the
built spec/ui and metadata-core functions now. It compared candidate
keys, key presence, slugs, whether each candidate is a form object from
the input, and the slug set:

| shape | bodies | with an open slug | BASE = spec/ui = metadata-core |
|---|---|---|---|
| nested `form` | 13 | 4 | 13 |
| `formViews` entry (plus an open sibling) | 13 | 13 | 13 |
| `viewKind: 'form'` + `config` | 13 | 4 | 13 |
| all three in one body | 13 | 4 | 13 |
| `config` without `viewKind: 'form'` | 13 | 0 | 13 |
| non-view input | 4 | 0 | 4 |
| real producers: showcase `inquiry.view.ts`, crm `lead.view.ts`, as
containers and as `expandViewContainer` items | 9 | 4 | 9 |

That is 78 bodies with 0 mismatches, and 21 leaf inputs
(`anonymousFormIntakeSlug`, `publicFormSlug`) with 0 mismatches.

**H1.** Lines 52-105 call nothing from `@objectstack/spec/security`,
`applyInjectedSystemColumns` or `resolveRecordWallOrganizationField`.
The new module has no imports, and the four bodies compile unchanged in
`packages/spec`.

**H2.** `./ui` is in `browser-reachable-entries.json`'s `unjudged` list,
so `check:browser-reachable-entries` asserts nothing about it (it
passed). The module is plain `function` declarations with no top-level
statements, and the package declares `"sideEffects": false`.

**Declaration surface downstream.** `metadata-core`'s `dist/index.d.ts`
and `.d.cts` now reference `@objectstack/spec/ui`. Measured with `tsc
--noEmit --extendedDiagnostics --listFiles`, building metadata-core from
BASE source and then from HEAD source. These are absolute numbers from a
shared box:

| program | files BASE → HEAD | memory BASE → HEAD |
|---|---|---|
| `packages/rest` | 579 → 580 | 1,246,583K → 1,268,472K |
| `packages/objectql` | 574 → 574 | 983,063K → 994,921K |
| `packages/plugins/plugin-security` | 495 → 495 | 1,079,234K →
1,084,561K |
| `packages/metadata-protocol` | 808 → 808 | 1,319,331K → 1,318,969K |
| `packages/qa/http-conformance` | 345 → 345 | 357,199K → 357,044K |

The one new file in `rest` is `spec/dist/ui/index.d.ts`, the CJS barrel,
reached through `metadata-core/dist/index.d.cts`. The chunks it
re-exports were already in that program in both flavours. tsc exited 0
in every program on both builds.

## Prose that named the old home (H4)

- `packages/spec/src/ui/sharing.zod.ts:19-23` said the rule lives in
`anonymousFormIntakeCandidates` "in `@objectstack/metadata-core`". It
now names `anonymous-form-intake.ts` beside that module, which
metadata-core re-exports to the server's doors.
- `content/docs/references/ui/sharing.mdx:22-26` was regenerated from
that docblock by `gen:docs`, not edited by hand.
- `packages/metadata-core/src/anonymous-form-intake.ts:13-20`: the
module docblock says the candidates half is declared in
`@objectstack/spec/ui`, whose docblock is now the authority on it. The
scan-shape paragraph moved with the code.
- Judged still true and left alone:
- `packages/rest/src/rest-server.ts:10698` reads
"(`anonymousFormIntakeCandidates`, `@objectstack/metadata-core`)". That
is where rest imports the function from, and metadata-core still exports
it. The file is also outside this card's surface.
- `packages/metadata-core/src/index.ts:141-145` ("both read this one
rule").
- The `docs/qa/platform-checklist` mechanism references name
`anonymousFormIntakeWithdrawnIn` and `anonymousFormExplicitWithdrawals`,
which stay in metadata-core.

## Verification (HEAD `cfdc8804f0`; the source tree is the same as
`3e9a9e48b1` plus the changeset)

- `pnpm --filter @objectstack/spec build` (JS + DTS): exit 0.
`check-dts-emitted` reported 38/38.
- `pnpm --filter @objectstack/spec check:generated`: all 15 artifacts up
to date. Before regeneration, 3 were stale: `api-surface/` (+5 rows in
`ui.json`), `export-origins/` (+5) and `content/docs/references/**` (the
H4 sentence). They were regenerated with `gen:api-surface`,
`gen:export-origins` and `gen:docs`.
- `check:api-surface`, `check:export-origins`, `check:docs`,
`check:exported-any`, `check:dual-source-exports` (0 accepted
dual-source), `check:entry-nameability`,
`check:browser-reachable-entries`, `check:liveness`, `check:llms-txt`,
`check:skill-examples`: exit 0.
- `pnpm --filter @objectstack/spec test`: 620 files, 18511 passed, 1
todo. The new file alone: 26 passed.
- `pnpm --filter @objectstack/metadata-core test`: 18 files, 415 passed.
`src/anonymous-form-intake.test.ts` alone: 46 passed.
- `pnpm --filter @objectstack/metadata-protocol test`: 219 files passed,
3 skipped; 28135 tests passed. The focused run of
`runtime-authoring-gate.public-form-intake`,
`protocol.runtime-authoring-gate` and
`protocol.org-scoped-write-refused` passed 257.
- `pnpm --filter @objectstack/rest test`: 260 files, 4914 passed, 326
skipped. The focused run of `public-form-routes`,
`public-form-routes.stored-row`, `public-form-withdrawal` and
`public-form-intake-availability` passed 76.
- `pnpm --filter @objectstack/spec typecheck` and `pnpm --filter
@objectstack/metadata-core typecheck`: exit 0. `--listFiles` shows both
new test files are in their packages' test programs.
- Gates derived by `node scripts/pm/dispatch-gates.mjs --commands` at
`cfdc8804f0`, and checked with `--ran`: 110 derived, 109 run, 0 unrun, 1
NOT MEASURED. The NOT MEASURED one is `pnpm check:dual-build-cjs-loads`,
exit 3, PREREQUISITE NOT MET: it needs every package built. The CJS half
of the H5 probe above loaded `metadata-core/dist/index.cjs` with
`require`. The 5 roster gates the lead list flagged under a touched
directory also pass (`check:meta-url-spelling` and `check:spec-changes`
through `check:generated`; `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`).
- Lint is a narrowed run, and it measures something: `eslint
--no-inline-config --format json` over the 6 changed TS files reported 6
files, 0 errors and 0 warnings. All 6 are in the population of
`eslint.config.mjs` (`files: ['**/*.{ts,…}']` minus `NEVER_LINTED`, and
`--print-config` resolves each one). That config never turns on
type-aware linting (no `parserOptions.project`, no typed rules; see its
own note near line 327), so this diff cannot change the result for any
file it does not touch. The repo-wide `pnpm lint` is left to CI.

## Acceptance notes

- **Not covered by this card** (the card's "Not this card" section):
whether another layer withdraws a form, whether the posture makes a form
unavailable, any server-side "published" answer, and the objectui page
change. objectui#11545 restarts once objectui uses a release that
carries these exports.
- `main` gained objectstack-ai#22021 (a spec analytics change) after this branch was
cut. It touches none of these files and none of the generated artifacts,
so `main` is not merged here, and the merge queue rebuilds on the
current `main`.

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

---------

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants