Skip to content

feat(spec)!: the analytics row wildcard '*' is admitted only where a count consumes it (#21409) - #21431

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21409-star-count-only
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21409-star-count-only

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21409
Clause-②: no (narrowing)

Dispatched by the PM claim 5953981594 (PM loop round 1, domain:spec seat 1), on the triage direction in the card body (the B answer 5952826307 to 5952467081). Session session_01UtnxvdiN376GF3sgXwAw4d.

The row wildcard '*' is what a count aggregates (COUNT(*)). It is now admitted in exactly one place, a measure that counts. Everywhere else it is refused at the authoring parse, naming the slot and prescribing a count or a column. The measured 500 DATABASE_ERROR at POST /api/v1/analytics/dataset/query is now a 400 VALIDATION_FAILED.

What changes (@objectstack/spec)

position slot refusal spelled as
1 cube measure MetricSchema.sql, under any type but count custom at sql refinement asking the ONE predicate
2 cube dimension DimensionSchema.sql invalid_format at sql pattern ANALYTICS_COLUMN_PATH (the dataset dimension's own)
3 dataset measure DatasetMeasureSchema.field, under any aggregate but count (or none: a derived measure) custom at field refinement asking the ONE predicate
control dataset dimension DatasetDimensionSchema.field invalid_format (since PR #21240) unchanged
  • One predicate. rowWildcardOutsideCount(reference, aggregate) and its refusal rowWildcardOutsideCountRefusal(slot, aggregateKey, aggregate) live in packages/spec/src/data/analytics-column-reference.ts, beside the column-reference grammar, outside the data barrel (not published API). Both measure refinements call them. Neither restates the rule. The pin asserts each issue IS the builder's output for its slot.
  • Dropped refinements. The two measure refinements are cross-field, so they cannot be a JSON-Schema pattern. They are declared in dropped-refinements.baseline.json: the roots data/Metric and ui/DatasetMeasure, plus the embedded sites the build printed (data/Cube, ui/Dataset, and the four installed-package API schemas). The measured counts move to 217 schemas / 652 sites. Position 2 is a pattern, so the published JSON Schema states it.
  • ADR-0087. One D3 entry: migrations/entries/semantic/18.analytics-row-wildcard-outside-count-refused.ts. registry.ts was regenerated by gen:migration-registry, never edited between markers. There is no D2 conversion: rewriting to count changes the figure the author asked for, and only the author can name a column. There is no RETIRED_KEYS_BY_MAJOR row, and no STEP18_RATIONALE fragment. That fragment is optional, and adding it would be a hand edit to registry.ts outside the claimed generated region. spec-changes.json and the upgrade guide stay at protocol 17, as for every major-18 entry, and both checks are green.
    • Why an entry. I judged this against cube-member-sql-expression-retired and dataset-member-field-expression-refused, the two accept-set narrowings of the same slots. Both register a D3 entry because a stored document needs a prescription and has no mechanical rewrite. The same holds here.
  • Liveness. analytics_cube measures.sql and dimensions.sql were the notes that pointed the count-only boundary at spec+service-analytics: retire the cube metric types number / string / boolean — they existed to carry a SQL expression, which #20943 retired from a member's sql #21000. They are re-pointed here. dataset measures.field states the narrowing. All three stay live, re-verified 2026-10-02.
  • Docs. content/docs/references/ui/dataset.mdx is regenerated: the measure field describe now says "*" is for a count.
  • Changeset. @objectstack/spec minor, with the BREAKING banner, the (narrowing) arm, FROM → TO and one ADR-0087 marker (registered analytics-row-wildcard-outside-count-refused).
  • Runtime. Unchanged. No strategy code in service-analytics was touched.

Zone 1, read as written — one point flagged, not silently chosen

The direction says "one cross-field rule per position, sharing one predicate". Position 2, a cube dimension, has no aggregate, so no rule there can be cross-field. Zone 2 item 2 says to find how the control (the dataset dimension, PR #21240) spells its '*' refusal and follow it. The control spells it as a pattern, ANALYTICS_COLUMN_PATH, not as a refinement. So position 2 takes that same pattern, and the two dimension slots now publish one identical pattern. The cross-field predicate covers the two measure slots, where an aggregate exists.

This is strictly stronger than a third refinement would be. The published JSON Schema carries this half, and no dropped-refinement row is needed for it. There is still one rule source (COLUMN_PATH, read twice) and one cross-field predicate (read twice). Nothing has a second spelling.

The PM's mechanism assumptions, measured

  1. Confirmed. On ceb4a939b4, analytics-column-reference.ts declared the shared grammar, and ANALYTICS_COLUMN_REFERENCE admitted '*' for every member. cube-member-sql-column-reference.test.ts pinned '*' on a cube dimension. That pin is now replaced by the refusal, because it pinned exactly the branch removed.
  2. Partly falsified. The control is a pattern, not a refinement (see above). The measure positions are refinements (superRefine chained on the strict objects, the DatasetSchema precedent), because only they are cross-field.
  3. Measured. What a stored document meets now:
    • At /meta reads. It is served as stored, with the refusal on _diagnostics. Probe through computeMetadataDiagnostics on the built spec: a stored dataset with { aggregate: 'sum', field: '*' } reads back as valid: false with measures.1.field / custom. A stored cube reads back as measures.total.sql / custom and dimensions.everything.sql / invalid_format. A re-save through the write door is refused at the slot.
    • At the dataset query door. The route parses every dataset it is handed, inline or saved. A stored dataset carrying such a measure is refused 400 VALIDATION_FAILED on every query. That includes a query that selects only its healthy count, which answered 200 before. It fails closed, never a stand-down, and the blast radius is the dataset. The door test pins both selections.
    • Stored analytics_cube rows. Read from code: these never reach the analytics registry. serve.ts feeds it from the stack definition's analyticsCubes only, and that parse (defineStack) refuses such a cube.

Census: no producer (triage's "no producer is known", measured)

  • This repo at ceb4a939b4. git grep of every field / sql value spelled '*' over examples, packages (fixtures included), content, skills, apps, scripts and docs found 173 hits. Each was read in its enclosing object literal: 154 under a count. The other 19 are QueryAST aggregations (function: 'sum', field: '*' in objectql conformance tests, which is not one of the three positions), comments, and strategy-level method: 'count' literals. Zero sit at a non-count cube measure, a cube dimension or a non-count dataset measure.
    • One more author was found through a loop variable: the cube-dimension accept pin above.
  • objectui at the .objectui-sha pin 89cad75d55. Read-only git grep at the pin found zero field / sql values spelled '*'. Lit controls: 51 aggregate: 'sum', 438 field: 'amount'. A '*' scan of the 302 files mentioning aggregate found only objectName: '*' bus events, query-builder '*' and i18n required marks. None is a dataset or cube slot.
  • Deployed metadata. NOT MEASURED.

The door cell: 500 → 400

packages/rest/src/analytics-dataset-row-wildcard-door.test.ts drives the real route over a real ObjectQL engine with a better-sqlite3 SqlDriver. It uses AnalyticsServicePlugin's own composition, once per strategy, with read counters proving which strategy answered.

  • Before. I ran this test against the BASE spec build (ceb4a939b4, dist verified free of the new predicate): Tests 20 failed | 4 passed (24).
    • Every inline cell answered {"error":"Internal server error","code":"DATABASE_ERROR"}: expected 500 to be 400. That held for sum, avg, min, max and count_distinct over '*', on both strategies.
    • The saved dataset, querying its healthy count, answered 200.
    • The four count controls were green.
  • After. On the fixed spec build: Tests 42 passed (42) (this file's 34 plus the neighbouring analytics-16019-driver-declared-fault.test.ts's 8).
    • Every refused cell answers 400 VALIDATION_FAILED with the issue at measures.2.field (custom).
    • Raw-SQL and engine-aggregate counters stay at 0.
    • The count-over-'*' controls answer the row counts, [{a,2,2},{b,1,1}], on native SQL (raw-SQL counter ≥ 1) and on ObjectQL (aggregate counter ≥ 1).
  • The cells for a saved dataset selecting the wildcard measure itself were added after the base run, so their base answer is NOT MEASURED. They compile the same measure the inline cells do.

Tests (final union at 60644d73d9, after the main merge)

  • pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2 gave Test Files 601 passed (601), Tests 17647 passed | 1 todo.
    • It includes the new src/data/analytics-row-wildcard-count-only.test.ts (31 cases: every non-count AggregationMetricType and AggregationFunction option, every DimensionType, the derived case, the controls, CubeSchema and the analytics_cube door, defineCube, DatasetSchema and the dataset door, defineStack with STACK_SCHEMA_INVALID / 422, the JSON-Schema halves with the ledger rows, and the D3 entry).
  • pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/analytics-dataset-row-wildcard-door.test.ts src/analytics-16019-driver-declared-fault.test.ts gave Tests 42 passed (42).
  • Also run before the merge (934b70a2db; the incoming main commits touch neither package):
    • rest --project local: 256 passed (256) files, 4848 tests.
    • service-analytics, as the main consumer of both shapes: 168 passed (168) files, 3794 tests.
    • spec repo-project subset (cube-member-inner-name-retirement, cube-refresh-key-retirement, step18-rationale-merge, liveness/evidence, liveness/proof-registry): 131 passed.
    • pnpm --filter @objectstack/spec typecheck and pnpm --filter @objectstack/rest typecheck: exit 0.
  • The whole spec repo project (48 files) is NOT MEASURED locally. One run exceeded the foreground cap, so it is declared to CI.

Ablation (one-shot, not kept)

From the committed fix, through scripts/ablation-replace.mjs, rowWildcardOutsideCount was made to answer false (anchor 1 → 0, marker 0 → 1, blob 2bb692602dc8 → 4bdfba658269). The new spec file went 19 failed | 12 passed (31). Red: the predicate table, every position-1 and position-3 cell, and the four door cases. Green: position 2 (a pattern, untouched by the predicate), every control, the JSON-Schema halves and the D3 pin. That is the predicted direction. Restored with git checkout HEAD --: blob equals the HEAD blob and git diff HEAD is empty, under a trap on EXIT/INT/TERM. The spec suite imports the source by relative path, so no dist/ sits on its resolution path.

Gates

  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths, merge base 39a912ea7) derived 115 families. I ran all 115, and --ran reconciles 113 run green, 2 NOT MEASURED (exit 3, prerequisite), 0 UNRUN:
    • pnpm check:dual-build-cjs-loads: needs every package's dist, which means a whole-repo build.
    • pnpm check:type-check-debt: its --re-measure builds the ledgered packages' closure itself, and that build passed the 300 s per-gate cap.
    • Both are whole-tree, and CI's Lint & Repo Gates runs them.
  • check:skill-examples first exited 3 (client-react unbuilt). After building client and client-react it exited 0 (259 prose examples type-check).
  • pnpm --filter @objectstack/spec check:generated passes all 15 artifacts after the merge. The only stale artifact before was content/docs/references/**, regenerated with gen:docs.
  • Clause-② measured. node scripts/pm/check-widening-tells.mjs --declaration no --diff (merge-base diff) exited 0 with no widening tell. It stated two silences: the rowWildcardOutsideCountRefusal( lines name an imported factory it does not resolve. The predicate is not exported from any published entry (check:api-surface green, artifacts byte-identical), so the arm is no (narrowing), as triage wrote.
  • ESLint (narrowed, a measurement).
    • Population: eslint.config.mjs lints **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} minus NEVER_LINTED, and all 8 changed .ts files fall inside it.
    • Count: --format json read back 8 files, 0 errors, 0 warnings.
    • Invariance: the config never enables type-aware linting (no parserOptions.project, no typed rules), so this diff cannot move a verdict on an untouched file.
    • The repo-wide pnpm lint is CI's.

Serial

Acceptance notes (not filed)

  • Runtime inference mints the same shape, unreached by this parse. service-analytics inferMeasure turns a caller-named measure with an empty prefix (_sum, _avg, _min, _max, _count_distinct) into { type: 'sum' …, sql: '*' } (key.slice(0, -suffix.length) || '*'). The caller-measure gate admits inferredSql === '*'. Read from code only, NOT MEASURED at a door, and outside this card's no-strategy-edit surface. Carrier: the domain:services lane; no carrier named.
  • A derived dataset measure's field is read by nothing. The compiler skips it. This card now refuses '*' there, but any column value still parses inert. Observed while reading the compiler; no producer found. Carrier: none named.
  • spec+service-analytics: retire the cube metric types number / string / boolean — they existed to carry a SQL expression, which #20943 retired from a member's sql #21000's enum retirement is untouched. AggregationMetricType number / string / boolean are still covered by the predicate's "anything but count" for as long as they exist.

Generated by Claude Code

claude added 5 commits October 2, 2026 14:06
… '*'

The door cell the narrowing moves: a dataset measure aggregating the row
wildcard under any aggregate other than count, inline and saved, on both
strategies, beside the count-over-'*' controls.

Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
Co-authored-by: Claude <noreply@anthropic.com>
…t consumes it

A cube measure's sql and a dataset measure's field admit '*' only under
count, by one shared predicate (rowWildcardOutsideCount) both measure
refinements call; a cube dimension's sql takes the column path without
the wildcard arm, the dataset dimension's own pattern. One ADR-0087 D3
entry, the regenerated registry region, and the liveness notes
re-pointed here. Generated artifacts and the dropped-refinement ledger
follow in the next commit.

Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
Co-authored-by: Claude <noreply@anthropic.com>
…sites and regenerate the dataset reference page

Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
Co-authored-by: Claude <noreply@anthropic.com>
…minor, narrowing)

Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
Co-authored-by: Claude <noreply@anthropic.com>
…ar-count-only

# Conflicts:
#	packages/spec/dropped-refinements.baseline.json
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx (via DimensionSchema (symbol, a top-level const), MetricSchema (symbol, a top-level const))
  • content/docs/data-modeling/analytics.mdx (via DatasetMeasureSchema (symbol, a top-level const))

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

  • content/docs/releases/v16.mdx (via queryDataset (sdk, the bare tail of client method analytics.queryDataset, bound to POST /api/v1/analytics/dataset/query))
  • content/docs/releases/v17/17-2.mdx (via MetricSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-3.mdx (via analytics.queryDataset (sdk, the route ledger binds it to POST /api/v1/analytics/dataset/query), queryDataset (sdk, the bare tail of client method analytics.queryDataset, bound to POST /api/v1/analytics/dataset/query))
  • content/docs/releases/v17/17-5.mdx (via queryDataset (sdk, the bare tail of client method analytics.queryDataset, bound to POST /api/v1/analytics/dataset/query))
  • content/docs/releases/v17/17-6.mdx (via /api/v1/analytics/dataset/query (route, a path literal in reason; a path literal in semantic))
  • content/docs/releases/v9.mdx (via queryDataset (sdk, the bare tail of client method analytics.queryDataset, bound to POST /api/v1/analytics/dataset/query))

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
  • 3 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json, packages/spec/liveness/analytics_cube.json, packages/spec/liveness/dataset.json) — pages documenting those are invisible to this run
  • 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 — 138 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 3a6d92f78bb6a160b762dfee0738fd3b0b7ae6c2 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 3a6d92f78bb6a160b762dfee0738fd3b0b7ae6c2 → 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: 60644d73d981410172f2ccc5ab6a81d5947faad7
Local-runs: none

Reviewed at 2026-10-02T16:07Z. Inputs: card #21409 (body, claim 5953981594, dev report 5955982656), PR #21431 (body, 13-file list, net diff origin/main... the head), the head's check-runs, origin/main for the three schema files and the control commit 434c6c7cab (#21240), service-analytics read only for the dev's out-of-scope finding, objectui at pin 89cad75d55. Read-only: nothing built, run or re-run. The diff is five commits on top of merge-base 39a912ea73; no packages/metadata-protocol path is in it (the throwaway probe is absent, as reported).

① Derived judgments

  1. Position 1, cube measure MetricSchema.sql — RIGHT. Base: .regex(ANALYTICS_COLUMN_REFERENCE) admitted '*' under every type. Head: the same regex plus a root superRefine that adds custom at ['sql'] when sql === '*' and type !== 'count'. type is required (AggregationMetricType), so the predicate's "no aggregate" arm is unreachable here. Accept-set delta: exactly '*' under a non-count type; a column, a dotted path and count over '*' parse byte-identically (pinned), and an expression was already refused at base ([Decision] analytics field gate (#20917): an authored cube member whose sql is an expression — keep the stand-down, judge its identifiers, refuse it, or retire expressions #20943).
  2. Position 2, cube dimension DimensionSchema.sql — RIGHT, and the pattern is the right form. Base regex CUBE_MEMBER_SQL is ANALYTICS_COLUMN_REFERENCE, i.e. ^(?:\*|COLUMN_PATH)$; head regex ANALYTICS_COLUMN_PATH is ^COLUMN_PATH$, both built from the one COLUMN_PATH source. The delta is exactly '*': an expression, a quoted or $-prefixed spelling, a function call, an empty string and a broken path were refused at base already (the EXPRESSIONS pin in cube-member-sql-column-reference.test.ts still holds on both members). Census of authored dimension sql values: examples/**, packages/platform-objects, skills/**, content/** author no dimension over '*' (the 180 in-repo field/sql hits spelled '*' at origin/main are count measures, QueryAST aggregations, comments or runtime literals); the one in-repo author was the spec pin the diff moves to the refusal; objectui at the pin has zero field/sql values spelled '*' in any quoting. On the card's "one cross-field rule per position, sharing one predicate; no second spelling": a dimension has no aggregate, so no cross-field rule exists for it; the card's own control (dataset dimension field, feat(spec)!: an analytics dataset dimension's and measure's field is a column reference (#21220) #21240 at 434c6c7cab) refuses '*' by this very pattern; the pattern reaches the published JSON Schema where a refinement would not and would have cost a ledger row. Position 2 has one spelling (the pattern) and the two measure positions share one predicate, so nothing is spelled twice. The refusal text names the slot, says '*' is no dimension and prescribes a count measure or the column; code invalid_format is the pattern's own.
  3. Position 3, dataset measure DatasetMeasureSchema.field — RIGHT. Base: .regex(ANALYTICS_COLUMN_REFERENCE).optional() admitted '*' under any aggregate and with none. Head: root superRefine, custom at ['field'] when field === '*' and aggregate !== 'count', the absent aggregate included (a derived measure; a non-derived measure without an aggregate was already refused by DatasetSchema's own refinement). Delta: exactly '*' outside count. The derived arm is inside the card's direction ("admitted only where a count consumes it; elsewhere refused") and the PR table states it. Controls pinned: count over '*', a count with no field, every non-count aggregate over a column, and the dataset dimension still refusing '*' with invalid_format.
  4. Refusal, code and path — RIGHT. rowWildcardOutsideCountRefusal(slot, aggregateKey, aggregate) names the slot (the measure's sql, or measures[].field) and the aggregate written (under type: 'sum', or with no aggregate), and prescribes the two ways out, count or a column (amount, account.amount). The pins assert code custom, path ['sql'] / ['field'], and the message byte-equal to the builder for every non-count enum member (derived from the enums, not restated); every door is pinned (CubeSchema, the analytics_cube write door, defineCube, DatasetSchema, the dataset write door, defineStack with 422 STACK_SCHEMA_INVALID and exactly three issues).
  5. Door cell packages/rest/src/analytics-dataset-row-wildcard-door.test.ts — RIGHT. The real route over better-sqlite3 through AnalyticsServicePlugin's own composition, once per strategy, with read counters. Refused cells (inline and saved, five non-count aggregates) assert 400 VALIDATION_FAILED, custom at measures.2.field (index 2 is the wildcard measure in the fixture), and zero reads on both counters; the count controls assert 200 with the row counts and the answering strategy's counter. The 500 → 400 base reading is the dev's measurement on the base dist (the card's own record covers sum); the saved-wildcard-selected cells' base answer is NOT MEASURED, as the dev declared. The head's Test Core shards are what say it is green (see ③.8).
  6. Published surface — RIGHT, Clause-②: no (narrowing). analytics-column-reference.ts is imported only by analytics.zod.ts and dataset.zod.ts, never re-exported by src/data/index.ts; rowWildcardOutsideCount and its refusal builder are absent from packages/spec/api-surface/*.json on the head; api-surface/, api-surface-signatures.json and json-schema.manifest/ are untouched by the diff; check:api-surface ran green on the head (Type Check · consumer gates). .superRefine() on zod 4.6.5 returns the same object schema, so the MetricSchema.shape readers (analytics-strictness-batchd.test.ts, build-schemas-check-mode.test.ts) are unaffected. No new export, key or enum member.
  7. JSON Schema leg and the ledger — RIGHT. The two measure refinements are custom checks z.toJSONSchema drops, so each site is ledgered: roots data/Metric and ui/DatasetMeasure at ""; containers data/Cube at measures.valueType (the record-value spelling the ledger already uses) and ui/Dataset at measures.element; the four installed-package API schemas at two embedded paths each. 2 + 2 + 8 = 12 sites; counts 215 → 217 (two new schema rows) and 640 → 652. build-schemas.ts refuses a ledger that does not match the tree and runs inside the spec build, which Build Core ran green on this head. The dimension half is a pattern and needs no row; the pin holds the cube dimension's published pattern equal to the dataset dimension's.
  8. ADR-0087 entry 18.analytics-row-wildcard-outside-count-refused.ts — RIGHT. surface names the three positions; replacement prescribes a count, a column, or deleting field on a derived measure; acceptanceCriteria states the three positions with their codes, the count exemption (count over '*', a dataset count with no field), every door, and the measured 500 becoming a figure. No D2: there is no lossless rewrite. No RETIRED_KEYS_BY_MAJOR row: no key leaves any shape, and a value narrowing is not a key retirement. The registry.ts region is regenerated (check:migration-registry, green in Lint & Repo Gates).
  9. Liveness rows — RIGHT. analytics_cube measures.sql and dimensions.sql no longer point the count-only boundary at spec+service-analytics: retire the cube metric types number / string / boolean — they existed to carry a SQL expression, which #20943 retired from a member's sql #21000; each states the analytics: '*' runs only under count, but a cube measure's or dimension's sql and a dataset measure's field admit it under any aggregate — a summed '*' answers 500 at the dataset door (split from #21000) #21409 narrowing, its form (predicate / pattern) and the D3 id; dataset measures.field states the narrowing and its ledger row. All three stay live, correctly: the keys are still read at the cited symbols (dataset-compiler.ts sql: m.field ?? '*', both strategies). Spec property liveness is green on the head.

② Semver level

  • @objectstack/spec minor, BREAKING banner, Clause-②: no (narrowing) on the PR body and in the changeset, exactly one marker (registered analytics-row-wildcard-outside-count-refused): the right level for a published accept-set narrowing with no new export under the launch-window convention (AGENTS.md Post-Task step 3). Check Changeset (ADR-0087 registration, no-major) is green on the head.
  • Sentence by sentence the changeset states what ships: the three bullets match ①.1–3; "each refusal names the slot and the aggregate ... prescribes a count or a column" matches the builder; the byte-identical controls are pinned; the stored-document paragraph discloses the fail-closed answer on a saved dataset's other measures, which the door test pins; the kit bullets (non-public predicate, D3 without D2 or retired-key row, 12 ledger sites, liveness, regenerated ui/dataset page, runtime untouched) match the diff; the reach paragraph matches my census (in-repo and objectui at the pin). The five-aggregate 500 reading is the dev's base measurement, stated as such.
  • One sentence is not literally true, for the seat's ACCEPT (prose face, outside this record's verdict): the FROM → TO block attributes the outcomes parsed (FROM) and ZodError at measures.0.field (custom) (TO) to a defineDataset({ ... }) call. defineDataset is an identity function (return dataset;) on base and head alike; that call parses nothing and throws nothing. The ZodError surfaces where the dataset is parsed: DatasetSchema.parse, defineStack({ datasets }) (422 STACK_SCHEMA_INVALID), the dataset write door, and the query route (400). The cube half (defineCube) does parse, so that line is right. The shape mapping and the one-line fix are correct. Since the changeset ships as CHANGELOG and cannot be amended after release, I ask the seat to have the dataset FROM → TO spelled against DatasetSchema.parse or defineStack before landing.
  • content/docs/references/ui/dataset.mdx: the two regenerated rows mirror the new .describe() text ("*" for a count); check:docs is green on the head (Type Check · source gates).

③ Boundary flags

open_questions is empty. Each deviation in report 5955982656:

  1. Dimension spelled as a pattern — answered in ①.2: the right form, the control's own, flagged not silently chosen; no accept-set change beyond '*'.
  2. "Zone 2 item 2 partly falsified: the control is a pattern" — confirmed at 434c6c7cab: DatasetDimensionSchema.field is .regex(ANALYTICS_COLUMN_PATH). The dev read the control correctly.
  3. No STEP18_RATIONALE fragment — REPORTING, and a follow-up for the seat. The fragment list is hand-written text outside the generated markers, no gate requires one, and adding it would have exceeded the claimed generated-region surface, so stopping and flagging was right. But both sibling narrowings of these slots (cube-member-sql-expression-retired, dataset-member-field-expression-refused) carry a fragment, and build-upgrade-guide.ts renders the joined step-18 rationale into the upgrade guide, so that page's narrative currently omits this narrowing while its table row appears. A one-fragment docs follow-up, not a contract gap.
  4. verifiedAt bumped to 2026-10-02 on the three rows — accepted. The evidence anchors are unchanged symbol anchors; the dev declares a re-read; the README allows the stamp only with a reading; the gate accepts the date. REPORTING.
  5. Merge commit 60644d73d9 without the trailer pair — REPORTING, no contract effect: the four authored commits carry the pair, the repository is squash-only (allow_squash_merge alone, message from the PR body), so the merge message never reaches main.
  6. dropped-refinements.baseline.json conflict resolved from main's blob plus re-declared sites — RIGHT: the net diff against main is exactly the 12 declared sites and the two recomputed counts, main's rows (feat(spec)!: an agent's memory contract states exactly what the runtime honours — maxEntries and reflectionInterval are required once long-term memory is enabled, longTerm.store is retired, and the block is live #21413's included) retained; the build validates the ledger (①.7).
  7. Throwaway probe in packages/metadata-protocol — absent from the 13-file list and from git diff --stat origin/main...head. Confirmed.
  8. NOT MEASURED items, named to the check-run that measures each on this head: pnpm check:dual-build-cjs-loads → ci.yml job build-core, step "Every published require entry point actually loads" → Build Core, success. pnpm check:type-check-debt → lint.yml job typecheck-debt → Type Check · debt ledger, success. The whole spec repo vitest project → turbo run test test:repo inside the Test Core (1/6) … (6/6) shards → all six success, and the Test Core rollup success (concluded 2026-10-02T16:06Z). At my final read every one of the head's 35 check-runs had concluded: 33 success, 2 skipped by design (Console Pin Gate, path-filtered; Packed-tarball smoke (opt-in)), none failed, none still running. Also measured on the head: check:api-surface → Type Check · consumer gates, success; check:docs, check:spec-changes, check:upgrade-guide → Type Check · source gates, success; check:migration-registry, pnpm lint → Lint & Repo Gates, success; the ledger → Build Core, success.
  9. Out-of-scope finding A — service-analytics inferMeasure (escalated to the seat, nothing filed). Read at origin/main: inferMeasure('_sum') takes key.slice(0, -4) || '*' and mints { type: 'sum', sql: '*' }; assertMeasureFields returns no source for sql === '*'; assertCallerMembersResolvable admits inferredSql === '*' explicitly; NativeSQLStrategy#resolveMeasureSql emits the operand verbatim (measure.sql === '*' ? '*' : qualify...), so a caller measures: ['_sum'] (likewise _avg, _min, _max, _count_distinct) reaches the database as SUM(*) at POST /api/v1/analytics/query. Neither mint site parses the minted cube through CubeSchema, so this PR's spec refusal does not reach it. Same consequence (a server fault at a public door for a wildcard outside count), different class: a runtime-minted shape from a caller string, not an authored metadata shape the contract admits; the fix sits in the mint (the || '*' default belongs to the count key alone, or an empty prefix is refused as the dotted case is). NOT MEASURED at a door here. Carrier: domain:services.
  10. Out-of-scope finding B — a derived dataset measure's field is read by nothing. dataset-compiler.ts pushes a derived measure and continues before m.field is read; DatasetSchema's refinement refuses neither field nor aggregate beside derived, and the schema's own doc says aggregate is "ignored at compile time". That is the ADR-0049 declared-but-ignored shape on a key combination, an authoring trap per Prime Directive chore: version packages #10, so a finding rather than an observation: a candidate spec card to refuse field and aggregate beside derived (a cross-field narrowing with its own kit), low priority, no producer found. Escalated to the seat for the filing decision; nothing filed.

Implemented-by: claude/issue-21409-star-count-only
Reviewed-by: session_01UtnxvdiN376GF3sgXwAw4d

VERDICT: PASS

…that parse

defineDataset is an identity function and parses nothing; the dataset
half of the FROM/TO block now names DatasetSchema.parse, defineStack
(422) and the dataset query route (400), as measured.

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

Copy link
Copy Markdown
Contributor Author

CI red on b79d1be80d, not this PR's · domain:spec seat 1 (session_01UtnxvdiN376GF3sgXwAw4d) · 2026-10-02T16:31Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Reviewed at 2026-10-02T16:39Z. A narrow re-review of the one-commit delta over the PASS record 5956317829 (head 60644d73d9). Inputs: card #21409 (body, claim 5953981594, dev reports 5955982656 and 5956509330), PR #21431 (body, 13-file list, commit list, the net diff origin/main... the head and the delta 60644d73d9..b79d1be80d), the head's check-runs, and the code the corrected changeset block attributes its answers to, read at the head and at the merge-base 39a912ea73 (packages/spec/src/ui/dataset.zod.ts, packages/spec/src/data/analytics-column-reference.ts, packages/spec/src/stack.zod.ts, packages/spec/src/api/error-code-ledger.zod.ts, packages/rest/src/rest-server.ts, packages/cli/src/commands/migrate/meta.ts, packages/spec/src/migrations/chain.ts and registry.ts), plus the two tests on the head. Read-only: nothing built, run or re-run.

① Derived judgments

  1. The delta is the changeset alone — RIGHT, and every ① judgment of record 5956317829 stands. git diff --stat 60644d73d9..b79d1be80d is one file, .changeset/21409-analytics-row-wildcard-count-only.md, +8 / -4, all inside the FROM → TO fence; the commit's own stat says the same. The net diff origin/main...b79d1be80d (merge-base 39a912ea73, the second parent of the branch's main merge) is the same 13 files the PR lists and the previous record judged: no schema, test, ledger, liveness, registry, docs or rest path moved, and no packages/runtime or packages/metadata-protocol path is in it. No other path moved, so nothing here is named FAIL. The accept-set delta is therefore unchanged: positions 1 and 3 refuse '*' outside count through the one predicate (custom at sql / field), position 2 takes ANALYTICS_COLUMN_PATH (invalid_format), the published surface gains no export, key or enum member, and Clause-②: no (narrowing) is still the arm.

② Semver level

  • Level and declaration — carried, unchanged by the delta. @objectstack/spec minor, the BREAKING banner, Clause-②: no (narrowing) (changeset and PR body alike), exactly one ADR-0087 marker (registered analytics-row-wildcard-outside-count-refused): the delta edits nothing outside the FROM → TO fence, so the previous record's reading of the level holds. Check Changeset is green on this head.
  • The corrected dataset FROM → TO block, sentence by sentence against the code:
    1. "DatasetSchema.parse accepted it" (FROM, at base) — TRUE. At 39a912ea73 DatasetMeasureSchema.field is .regex(ANALYTICS_COLUMN_REFERENCE), whose pattern is ^(?:\*|COLUMN_PATH)$, so '*' passes under aggregate: 'sum'; DatasetSchema's own refinement refuses only a non-derived measure with no aggregate or a non-count measure with no field, neither of which the literal is; DatasetDimensionSchema.type is optional, so { name: 'stage', field: 'stage' } parses; name, label, object, dimensions, measures are the literal's only keys and all are declared on the strict object. The literal parses at base with no issue.
    2. "a dataset query selecting deals answered 500 DATABASE_ERROR" (FROM, at base) — TRUE as a measurement the record carries, not re-run here. The first dev report's base run of the door test (spec dist at ceb4a939b4) read {"error":"Internal server error","code":"DATABASE_ERROR"}: expected 500 to be 400 for the inline sum cell on both strategies, and the card body records the same cell from spec(dataset): a dataset dimension/measure field admits a SQL expression at author time; narrow it to a column reference, as #20943 did for a cube member's sql #21220's probe (5940326148 on spec+service-analytics: retire the cube metric types number / string / boolean — they existed to carry a SQL expression, which #20943 retired from a member's sql #21000). The literal is that cell's shape: an inline dataset, sum over '*', the wildcard measure selected; the base compiler lowers the measure's field verbatim (packages/services/service-analytics/src/dataset-compiler.ts, sql: m.field ?? '*'), so SUM(*) reached the driver.
    3. "DatasetSchema.parse throws a ZodError at measures.0.field (custom)" (TO) — TRUE. On the head DatasetMeasureSchema ends in a .superRefine that asks rowWildcardOutsideCount(measure.field, measure.aggregate) and adds { code: 'custom', path: ['field'], message: rowWildcardOutsideCountRefusal('measures[].field', 'aggregate', measure.aggregate) }; nested in DatasetSchema.measures, the path is ['measures', 0, 'field'], and .parse throws the ZodError. The quoted opening of the message (measures[].field is the row wildcard '*' under aggregate: 'sum') is the builder's first sentence verbatim. The spec pin asserts exactly one issue, ['custom', ['measures', 1, 'field']], for its two-measure fixture through DatasetSchema and the dataset write door.
    4. "defineStack({ datasets }) refuses it at datasets.N.measures.0.field (422 STACK_SCHEMA_INVALID)" (TO) — TRUE. ObjectStackDefinitionSchema.datasets is z.array(DatasetSchema).optional(); buildDefinedStack runs ObjectStackDefinitionSchema.safeParse(normalized, ...) and on failure throws StackSchemaInvalidError(formatZodError(...), result.error.issues), whose code is 'STACK_SCHEMA_INVALID' and whose status is the base class's 422; the ADR-0112 ledger row says the same. The spec pin asserts code, status and the path ['datasets', 0, 'measures', 1, 'field'] among exactly three issues, so datasets.N.measures.0.field is the right spelling for a dataset at index N whose wildcard measure sits at index 0.
    5. "POST /api/v1/analytics/dataset/query answers 400 VALIDATION_FAILED for an inline or a saved copy" (TO) — TRUE. In rest-server.ts the route takes body.dataset or, failing that, loads the saved body.datasetName item through getMetaItems and translateMetaItem; both branches converge on stripReadDecorations and then DatasetSchema.parse(dataset) inside a try whose catch answers res.status(400).json({ code: 'VALIDATION_FAILED', message: 'Invalid dataset definition.', detail }) before svc.queryDataset is reached. The door test pins both branches on both strategies (five aggregates inline, five saved selecting the wildcard, five saved selecting only the count), with custom at measures.2.field in detail and zero reads on both counters. Its base answer for the saved-wildcard-selected cells stays NOT MEASURED, as declared before.
  • The rest of the changeset, re-read for attributions to a call that does not parse: defineCube({...}) in the cube half parses (return CubeSchema.parse(config)), so its FROM → TO line is right; "a re-save through the metadata write door is refused at the slot" names the dataset binding, which is DatasetSchema (pinned); "the stack definition, whose parse refuses it" names defineStack, which parses analyticsCubes through CubeSchema; no defineDataset attribution remains anywhere in the changeset.
  • One sentence is not literally true, for the seat's ACCEPT (prose face, outside this record's verdict): the one-line-fix paragraph ends "There is no mechanical rewrite, so os migrate meta lists nothing for it." The first half is right (no D2 conversion; the entry's conversionIds is empty). The second is not: os migrate meta's default chain runs to CHAIN_TERMINUS_MAJOR = Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS), which is 18 on this head (PROTOCOL_VERSION is 17.0.0; step 18 is registered); applyMetaMigrations maps every step.semantic entry of every hop crossed into a MigrationTodo; and printMigrationReport prints each one under "manual change(s) require your judgment" with its surface → replacement, why and verify, by design never dropped or filtered ("ADR-0087 D3 is never silence"). So on --from 17 the tool lists this entry; what it does not do is rewrite anything for it. Same class as the previous record's defineDataset finding: the mapping and the one-line fix are right, the tool attribution beside them is wrong, and the text ships as CHANGELOG. I ask the seat to have it spelled "rewrites nothing for it and lists the entry as a manual notice", or dropped, before landing.

③ Boundary flags

open_questions is empty in both reports. The round-2 deviations and findings in 5956509330:

  1. The probe sat in packages/runtime (src/zz-probe-21409-inferred-wildcard.test.ts), beside the runtime door test — absent, confirmed. It is in none of the PR's 13 files; git log --name-only 39a912ea73..b79d1be80d over every commit reachable from the head (the six branch commits and the main side of the merge) names no packages/runtime path and no zz-probe or metadata-protocol path; git ls-tree -r b79d1be80d carries no such file. The placement itself was right for what it measured: POST /api/v1/analytics/query is the runtime dispatcher's route, which @objectstack/rest does not mount. REPORTING, no contract effect.
  2. The changeset commit (b79d1be80d, authored 2026-10-02T16:14:21Z) preceded the probe, which was never committed. The order cannot be read from the remote — an uncommitted file leaves no trace — and the dev states it plainly with a clean-status proof after the deletion. Its consequence is what matters here: the probe measured finding A (a caller-named _sum / _avg minted by inferMeasure at the cube query door), which is outside this card's surface and outside the changeset's subject; the changeset claims the authoring parse and the dataset door and says "Runtime. Unchanged.", and nothing in it claims the runtime refuses a minted SUM(*). No sentence was owed to the probe's reading, so the order moved no contract face. REPORTING.
  3. Out-of-scope finding A (class a, now measured: 500 DATABASE_ERROR at POST /api/v1/analytics/query for _sum / _avg on both strategies, on an ad-hoc and on an authored cube, with the named-column controls at 200) and finding B (a derived dataset measure's field and aggregate are read by nothing) — the seat files them; no judgment is owed in this record.
  4. The round-1 deviations (5955982656) were answered in record 5956317829 ③.1–10; the delta touches none of their surfaces, so those answers stand.
  5. Check-runs on this head — not all green: one shard is red, and that bars landing until it is green, whatever this record's verdict says. At my final read every one of the head's 35 check-runs had concluded, none still running: 31 success, 2 skipped by design (Console Pin Gate, path-filtered; Packed-tarball smoke (opt-in)), and 2 failure — Test Core (4/6) and, on it, the Test Core rollup (concluded 2026-10-02T16:38:07Z). The failure is Test Core (4/6) (job 110922939161, step "Run this shard's tests", concluded 2026-10-02T16:30:29Z): turbo reports Failed: @objectstack/rest#test alone; the @objectstack/rest local vitest project read Test Files 1 failed | 255 passed (256), Tests 1 failed | 4847 passed | 322 skipped (5170), and the one red is src/import-template-route.test.ts, suite "the * agrees with the engine: starred exactly when the import door refuses a blank", case "for every shape of default the engine reads", with Error: Test timed out in 5000ms. Against the rule's exceptions: it is not a merge-base-same-signature red — main's own Test Core (4/6) is green at 3a6d92f78b (concluded 2026-10-02T16:31:35Z) and on the earlier main run, and the previous head 60644d73d9 had all six shards green — and it is not a red by design. What the evidence does say: the file is byte-identical between origin/main and the head, the PR's only packages/rest change is the new door test, and the delta under review is changeset-only, so the code tree this shard ran is the tree that passed the same shard on 60644d73d9; the red is one load-sensitive case (it boots a stack, registers one object per default shape, syncs schemas, then builds a workbook and imports a row for each shape, under vitest's default 5 s timeout) timing out, and the * in its title is the required-field mark on an import template, not the row wildcard this PR narrows. The dev's local rest --project local run at 934b70a2db reported the same population green (4848 passed, 322 skipped). A re-run is the seat's act, not this record's (read-only here); until that shard and the Test Core rollup are green on this head, the second landing condition is unmet. The other five shards (1/6, 2/6, 3/6, 5/6, 6/6) and the Dogfood Regression Gate rollup are success. The NOT MEASURED items the previous record mapped to check-runs read the same on this head: Build Core success (check:dual-build-cjs-loads), Type Check · debt ledger success (check:type-check-debt), Type Check · consumer gates success (check:api-surface), Type Check · source gates success (check:docs, check:spec-changes, check:upgrade-guide), Lint & Repo Gates success (check:migration-registry, pnpm lint), Spec property liveness success, Check Changeset success, Governed Surface Queue Guard success; the whole spec repo vitest project runs inside the six Test Core shards, whose only red is the rest timeout above.

Implemented-by: claude/issue-21409-star-count-only
Reviewed-by: session_01UtnxvdiN376GF3sgXwAw4d

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 30437505b8b1cbcff045b29fcc3d6eaccdc9e64c
Local-runs: none

Reviewed at 2026-10-02T17:18Z. A narrow re-review of the two-commit delta over the PASS record 5956873511 (head b79d1be80d), itself over 5956317829 (head 60644d73d9). Inputs: card #21409 (body, claim 5953981594, dev reports 5955982656, 5956509330 and 5957123353), PR #21431 (body, 13-file list, commit list, the net diff origin/main... the head, the delta b79d1be80d..fce0c9a042, and the merge 30437505b8 read against both of its parents and against main's own movement 39a912ea73..3a6d92f78b), the head's check-runs, and for ② the same code as last round (packages/spec/src/migrations/chain.ts and registry.ts, packages/cli/src/commands/migrate/meta.ts, the D3 entry). Read-only: nothing built, run or re-run.

① Derived judgments

  1. b79d1be80d..fce0c9a042 is the one changeset hunk and nothing else — RIGHT. git diff --stat is one file, .changeset/21409-analytics-row-wildcard-count-only.md, +3 / -1: the last sentence of the one-line-fix paragraph, respelled. No other path.
  2. The merge 30437505b8 is pure — RIGHT. Parents fce0c9a042 and 3a6d92f78b (origin/main at the time; the new merge-base). (a) The net diff origin/main...30437505b8 is the same 13 files both earlier records judged, 986 insertions / 59 deletions, and diffing it against the previous head's net diff leaves exactly the respelled hunk (the changeset's line count 119 → 121); no path was added or lost. (b) The first-parent diff fce0c9a042..30437505b8 names the same 45 files as main's movement 39a912ea73..3a6d92f78b, 2803 insertions / 245 deletions on both sides, byte-identical per file for 44 of them; the 45th, packages/spec/src/migrations/registry.ts, differs only in hunk context, and its added and removed lines are exactly main's 90 added and removed lines, with nothing of this branch's moves among them. (c) The second-parent diff 3a6d92f78b..30437505b8 equals the branch's own net diff, and on registry.ts its added lines are exactly the branch's own entry region, the same lines 39a912ea73..fce0c9a042 adds. (d) The generated region holds both entries once each: the generator inlines each entry object, and analytics-row-wildcard-outside-count-refused appears once at the head (its inlined entry's id), 0 times on main and once on fce0c9a042; main's dashboard-widget-single-series-multi-measure-refused appears at the head exactly as it does on main (3 textual hits for one entry: its own inlined id, plus two mentions of it inside a sibling entry's prose) and 0 times on the branch before the merge; feat(spec)!: an agent's memory contract states exactly what the runtime honours — maxEntries and reflectionInterval are required once long-term memory is enabled, longTerm.store is retired, and the block is live #21413's agent-memory-store-retired-and-limits-required is unchanged (3 / 3 / 3). entries/semantic holds 348 files at the head, 347 on each parent — main's set plus this branch's one. The head's registry.ts blob is a49da25ca28fe75334d02ee7243cd82f6e8b31b4, the blob the dev reports gen:migration-registry reproduced byte-identically; check:migration-registry on this head is measured by Lint & Repo Gates (③.3). No other path moved, so nothing here is named FAIL, and every ① judgment of the two earlier records stands.

② Semver level

  • Level and declaration — carried. @objectstack/spec minor, the BREAKING banner, Clause-②: no (narrowing) (changeset and PR body alike), exactly one ADR-0087 marker (registered analytics-row-wildcard-outside-count-refused): the delta changes one prose sentence and merges main, and neither moves the level. Check Changeset is green on this head.
  • The respelled sentence — TRUE against the code. "There is no mechanical rewrite: os migrate meta rewrites nothing for it, and lists the entry analytics-row-wildcard-outside-count-refused as a manual change that requires your judgment." The entry's conversionIds is empty, so applyMetaMigrations applies no edit for it: nothing is rewritten. meta.ts runs the chain to CHAIN_TERMINUS_MAJOR = Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS), 18 on this head; composeMigrationChain(17, 18) keeps every registered major above 17 and at most 18, so step 18 is crossed; chain.ts maps every step.semantic entry of each crossed step into a MigrationTodo; and printMigrationReport prints the header N manual change(s) require your judgment: followed by one [protocol 18] surface → replacement line per entry with its why and verify, by design never dropped, filtered or summarised. The sentence's words track that header. One nuance, not an untruth: the printed line carries the entry's surface → replacement headline (the three slots — a cube measure's sql, a cube dimension's sql and a dataset measure's field — authored as the row wildcard where no count consumes it, → what the member meant: a count, a column, or no field on a derived measure), not the id string; the changeset names the entry by the id the ADR-0087 marker and the registry use, which is how a reader of the CHANGELOG finds it.
  • The whole changeset, re-read one last time for a tool or door attribution that is not literally true: none remains. Each attribution names a call that does what the sentence says: DatasetSchema.parse (parses; refuses at measures.0.field), defineStack (ObjectStackDefinitionSchema.safeParse; 422 STACK_SCHEMA_INVALID), POST /api/v1/analytics/dataset/query (parses inline and saved copies; 400 VALIDATION_FAILED), defineCube (CubeSchema.parse), the dataset metadata write door (DatasetSchema), the stack definition's parse for authored cubes, the read-path _diagnostics (the dev's measured computeMetadataDiagnostics reading, carried), and now os migrate meta. The measurement sentences (the five-aggregate 500 at the dataset door; the two censuses) are stated as measurements and carry from the earlier records.

③ Boundary flags

open_questions and out_of_scope_findings are both empty in 5957123353.

  1. The merge commit 30437505b8 carries git's default message without the trailer pair — REPORTING, no contract effect, as for 60644d73d9 before it. The repository allows squash merges only (allow_squash_merge true; merge commits and rebase merges off) with the PR body as the squash message, so no merge-commit message reaches main; fce0c9a042 and the four authored commits carry the pair. The dev's reason for not amending (not re-entering the pre-commit regen deferral on a merge commit) is a process note for the seat, not a contract matter.
  2. Earlier rounds' flags (5955982656 deviations 1–8; 5956509330 deviations 1–2 and findings A / B) were answered in records 5956317829 and 5956873511; this delta touches none of their surfaces, so those answers stand. Finding A (inferMeasure minting SUM(*) at POST /api/v1/analytics/query) and finding B (a derived dataset measure's inert field / aggregate) remain the seat's to file.
  3. Check-runs on this head — all green. At my final read every one of the head's 35 check-runs had concluded, none still running: 33 success, 2 skipped by design (Console Pin Gate, path-filtered; Packed-tarball smoke (opt-in)), 0 failure. Each conclusion: success — Auto Label, Build Core, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate and its shards 1/3, 2/3, 3/3, Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance (live PG + MySQL), Test Core and its shards 1/6 through 6/6, The card this PR closes must claim this branch, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check, filter; skipped — the two named above. In particular Test Core (4/6) is success (concluded 2026-10-02T17:07:25Z) and the Test Core rollup is success (2026-10-02T17:16:35Z): the Flaky on Test Core: packages/rest import-template-route.test.ts › "the * agrees with the engine … for every shape of default the engine reads" times out at 5000ms on PRs that touch no packages/rest file #21428 timeout in import-template-route.test.ts that reddened both on b79d1be80d did not recur, and the @objectstack/rest population — the new door test included, now over the merged tree that carries main's native-SQL strategy changes the dev declared not re-run locally — ran green in that shard. The NOT MEASURED mappings of the earlier records hold on this head: Build Core (check:dual-build-cjs-loads), Type Check · debt ledger (check:type-check-debt), Type Check · consumer gates (check:api-surface), Type Check · source gates (check:docs, check:spec-changes, check:upgrade-guide), Lint & Repo Gates (check:migration-registry on the merged registry, pnpm lint), Spec property liveness, Check Changeset, Governed Surface Queue Guard, all success; the whole spec repo vitest project ran inside the six green Test Core shards. Both landing conditions are met on this head: this PASS record, and every check green.

Implemented-by: claude/issue-21409-star-count-only
Reviewed-by: session_01UtnxvdiN376GF3sgXwAw4d

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 17:20
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 17:20
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit b793010 Oct 2, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21409-star-count-only branch October 2, 2026 17:44
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…string / boolean, refused in both analytics strategies in the spec's words (objectstack-ai#21000) (objectstack-ai#21452)

Fixes objectstack-ai#21000
Clause-②: no (narrowing)

Dispatched by the claim `5955932074` (PM loop round 1, `domain:spec`
seat 1, session `session_01UtnxvdiN376GF3sgXwAw4d`), on triage's answer
B `5952826307`: the enum retirement only. The row wildcard boundary is
objectstack-ai#21409's (landed as `b79301000c`, merged here).

## What this does

**`@objectstack/spec`: `AggregationMetricType` loses `number`, `string`
and `boolean`** (ADR-0049 enforce-or-remove, grade `5923362062`).

- They declared "a custom SQL expression returning a number / string /
boolean": the measure's `sql` was the whole computation. Since ruling D
on objectstack-ai#20943 (`5d5e679873`), a cube member's `sql` is a column reference,
so the three had nothing left to compute.
- The enum is declared through `enumWithRetiredValues`
(`shared/retired-key.ts`), the house value-level mechanism. The six
aggregates (`count`, `sum`, `avg`, `min`, `max`, `count_distinct`) are
the whole vocabulary.
- An authored retired type fails `tsc`, because it is gone from the
type. It is refused at parse with a named prescription, at the enum, at
a metric's `type` and at a cube's `measures.METRIC.type`.
- The prescription names the aggregate that fits: `sum`, `avg`, `min` or
`max` over the column; `count` over `'*'` or over a column; or
`count_distinct`. A per-row value becomes a stored or formula field of
the object that the measure aggregates. A value derived from measures is
`derived: { op, of }` on an ADR-0021 dataset.
- A value the enum never declared keeps zod's own message.
- The prescriptions are module-private, so the export surface does not
grow.
- **ADR-0087:** D3 entry `cube-metric-expression-types-retired`
(`migrations/entries/semantic/`), with a step-18 rationale fragment
(order 62). `registry.ts` was regenerated by `gen:migration-registry`
after each merge, never edited by hand.
- There is no D2 conversion, by design: the column alone does not say
which aggregate the author meant, and a stored cube is refused, never
rewritten.
- There is no `RETIRED_KEYS_BY_MAJOR` row, because no key left the
shape.
- **Liveness:** the `analytics_cube` row `measures.type` stays `live`,
re-verified 2026-10-02. The narrowing is recorded, and the evidence now
names `aggregateOfMeasure` instead of the partition.
- **Generated:** only `content/docs/references/data/analytics.mdx`
moved, losing the three values from the enum list and from both `type`
cells.
- `api-surface`, `authorable-surface`, `json-schema.manifest` and
`api-surface-signatures` are byte-identical, as the playbook predicts
for an enum-value narrowing.
- `spec-changes.json` and the upgrade guide stay at protocol 17, and
both checks are green.
- The module header gained an `@module data/analytics` marker. Without
it, moving the imports below the header dropped the page's opening
paragraph: `lib/file-description.ts` rule 3 does not select a block
inside the import list without the marker. The page's description is
byte-identical to `main`.
- The neighbouring `CUBE_MEMBER_SQL` docblock no longer says that the
ObjectQL path refuses the partition, in the present tense.

**`@objectstack/service-analytics`: the `EXPRESSION_METRIC_TYPES`
partition is deleted.** It is replaced by ONE verdict both strategies
call, `aggregateOfMeasure` (`strategies/native-sql-strategy.ts`).

- `aggregateOfMeasure` admits a type this runtime lowers (the
`AGGREGATE_SQL` keys, pinned EQUAL to the enum's options). It refuses
everything else with the SPEC's own words,
`AggregationMetricType.safeParse(type)`, so the runtime keeps no list of
metric types, retired or otherwise.
- `NativeSQLStrategy#resolveMeasureSql` asks it before anything is
lowered. The verbatim emit is gone, and so is the unrecognised-type
throw it replaced.
- `ObjectQLStrategy#resolveMeasureAggregation` asks it at the one
resolver both doors call. The `INVALID_FIELD` arm for the partition is
gone.
- Comments that named the partition or the three types are updated:
`plugin.ts` (the bridge's comment, its docblock, and its runtime
message, which said "a custom-SQL measure is refused earlier"),
`preview-evaluator.ts`, `analytics-service.ts`,
`cube-measure-field-type-door.ts` and `dataset-refusal.ts`.

**`@objectstack/lint` (test and comment only).** objectstack-ai#21435 landed between
my merges with a pin asserting that the three types sit outside the
aggregate table. That is false after this retirement, so the assertion
now reads `[]`. The skip-5 `silent` case is kept. No changeset is
needed: a comment and a test, nothing in the published output changes.

## Clause-②, measured

`node scripts/pm/check-widening-tells.mjs --declaration no --diff` (the
merge-base diff against `b79301000c`) exits 0, with no widening tell.
Three key lines are reported as a stated silence. They are the
retired-member prescription entries `number:` / `string:` / `boolean:`
in the `enumWithRetiredValues` map, which are refusals, not accept-set
members.

No export-listing row was added (`check:api-surface` green,
byte-identical), so the line is `Clause-②: no (narrowing)`. Both
changesets are BREAKING, with `!`, a **BREAKING** banner, the
`(narrowing)` arm, exactly one ADR-0087 marker (`registered
cube-metric-expression-types-retired`) and `minor`.

## Census (examples, packages, platform objects, objectui)

The instrument is an AST walk over every object-literal member of a
`measures:` record. It covered 7,654 `.ts`/`.js` files under
`examples/**` and `packages/**` at `4ec505761d`, platform objects
included.

- 321 measure entries in total. Lit control: `count` 164, `sum` 71.
- Retired-type entries: 7. All are deliberate refusal fixtures in the
`service-analytics` tests, built without the parse.
- Zero hits in `examples/**`, in non-test `packages/**`, in `skills/**`
and in `content/docs/**` (one cube example there, `count` / `sum`).
- `examples/app-showcase/src/data/analytics/showcase.cube.ts`: 3
measures (`count`, `sum`, `avg`). Its three `type: 'string'` lines (48,
53, 63) are **dimensions**. `DimensionType` is a separate enum,
unchanged, and pinned in the new test file.
- JSON fixtures carrying record-form `measures`: zero.
- objectui at the `.objectui-sha` pin `89cad75d55`: 0 mentions of
`AggregationMetricType`, and 0 record-form measure entries over 528
files that mention `measures`. Control: `clientValidation.ts` names
`CubeSchema`. The Console Pin Gate is not at risk: no export left.

## Premise check (zone 2)

1. **Holds.** On `68c5ab7eba`, `AggregationMetricType`
(`data/analytics.zod.ts:27`) listed the three, and `MetricSchema.type`
used it. Measured through `AnalyticsService` with a column `sql`:
- the raw-SQL path SERVED the column unaggregated: `SELECT status AS
"status", amount AS "m" FROM "orders" GROUP BY status`;
   - the ObjectQL path refused the measure `INVALID_FIELD` / 400.
2. `api/analytics.zod.ts:231`: the `/analytics/meta` member's describe
("Aggregation type for a measure (`AggregationMetricType`)") is not made
false by the retirement, so it is not edited.
3. That `type` is a separate `z.string()` field, deliberately not the
enum, because the projection copies the value verbatim. Measured: it is
not the enum.

## What a stored cube carrying a retired type meets (fail closed, never
stood down)

**Pinned in `cube-metric-expression-types-retirement.test.ts`:**

- the artifact boot door (`ObjectStackDefinitionSchema`, the parse
`MetadataPlugin` runs a built artifact through) refuses it at
`analyticsCubes.0.measures.m.type` with the prescription;
- `defineStack` refuses it with `STACK_SCHEMA_INVALID` / 422;
- `defineCube` and the `analytics_cube` write door
(`getMetadataTypeSchema('analytics_cube')`, what `PUT
/api/v1/meta/analytics_cube/NAME` validates) refuse it too;
- the rehydration seam (`applyConversionsToStoredItem`) replays NOTHING
over it. Control: the same row's retired sub-day granularity IS
rewritten, so the seam is live. The stored row reaches the parse as
stored, and the parse refuses it.

**Measured through the real dispatcher routes** (a temporary
`packages/runtime` probe, not committed), for a cube a host registers
in-process WITHOUT the parse:

- `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql`, on
both strategies: `500`, with `error.message` carrying the spec's
prescription verbatim. Nothing executed.
- `sum` control: `200`.
- `GET /api/v1/analytics/meta`: `200`, listing the measure with `type:
"number"` as registered (see the Acceptance notes).

## Merges (serial constraints)

- `0d182f0549` merged `main` at `3a6d92f78b`, bringing PR objectstack-ai#21424
(objectstack-ai#21376, the NativeSQL filter-compile region) and PR objectstack-ai#21425 (objectstack-ai#21293, its
registry entry). Clean.
- `587d9d4b63` merged `main` at `d7d5b4f96a`. One hand conflict, in the
`objectql-strategy.ts` import from `native-sql-strategy.js`: objectstack-ai#21440
added `windowClauseSql`, and both are kept.
- `4ec505761d` merged `main` at `b79301000c`, bringing PR objectstack-ai#21431
(objectstack-ai#21409, the count-only boundary). One hand conflict, in the
`analytics.zod.ts` imports: `enumWithRetiredValues` and objectstack-ai#21409's
`ANALYTICS_COLUMN_PATH` / `rowWildcardOutsideCount` /
`rowWildcardOutsideCountRefusal`, both kept.
- Every merge went through `scripts/pm/os-regen-merge.sh`. Its step 4
was run each time, and `gen:migration-registry` afterwards wrote no
diff.
- **Registry reading at `4ec505761d`:** 349 semantic, 244 retired-key
and 212 retired-def entries. The siblings' ids
`analytics-row-wildcard-outside-count-refused` and
`dashboard-widget-single-series-multi-measure-refused` appear at the
same counts as on `main` (1 each).
`cube-metric-expression-types-retired` appears twice: the semantic entry
and its step-18 rationale fragment.
- **Ledger reading at `4ec505761d`:** the `measures.sql` and
`dimensions.sql` notes carry objectstack-ai#21409's count-only wording and no longer
name this card. `measures.type` carries this retirement's own wording.

## Tests (head `4ec505761d`)

- `@objectstack/spec`:
  - `vitest --project local`: 602 files, 17737 passed, 1 todo;
- `--project repo`: 43 of 49 files, 705 passed (the other six are NOT
MEASURED, below);
- `typecheck` (`tsc`, scripts, test layer): OK. The new
`@ts-expect-error` (a typed `Metric` with `type: 'number'`) sits in the
compiled test program.
- `@objectstack/service-analytics`: 170 files, 3846 passed, 126 skipped.
`typecheck` OK.
- The reverse verification happened on the way: a fixture typed `Cube`
with `type: 'number'` failed `tsc` with TS2322 against the rebuilt
`.d.ts` until it was cast.
- `@objectstack/lint`: 119 files, 5592 passed. `typecheck` OK.

## Ablations

Both run from the committed tree through `scripts/ablation-replace.mjs`.
In each, the anchor hit once and the blob changed; the restore was
proved by blob equal to `HEAD` and an empty `git diff HEAD`. Both
subjects resolve from `src`, so no rebuild was needed.

- **The runtime verdict admits every string** (`aggregateOfMeasure`'s
table check removed): 28 failed, 12 passed, over `metric-type-coverage`,
`measure-expression-both-strategies` and `measure-expression-sql`.
- Every refusal case went red: both strategies, both doors, and the
drift case.
- The admitted-aggregate, cross-object-twin and coverage-equality cases
stayed green, which is the predicted direction.
- **The spec's `number` prescription is unmapped:** 8 failed, 17 passed
in `cube-metric-expression-types-retirement.test.ts`. Every `number`
door pin went red; the `string` / `boolean` pins and the controls stayed
green.

## Gates

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` at `4ec505761d` derived 117 families. All 117 were run, each
exit code recorded, and `--ran` answered "117 derived, 117 run, 0
NOT-MEASURED (a DERIVED zero)". Among them:

- `check-adr-0087-registration` ("2 declared-breaking changeset(s), each
carrying an ADR-0087 disposition");
- `check-changeset-no-major`;
- `check:generated` ("All 15 generated artifacts are up to date");
- `check:liveness`, `check:doc-authoring`, `check:nul-bytes` and
`check:skill-examples` (after building the client closure);
- `check:type-check-debt` (325 s);
- `check:dual-build-cjs-loads` (after a whole-repo build, 71 of 72 tasks
cached).

**NOT MEASURED**
- `@objectstack/spec` repo-project files `build-schemas-check-mode`,
`dist-freshness`, `dist-freshness-adoption`,
`publish-smoke-boot-failure`, `publish-smoke-port-collision` and
`schema-tree-freshness`. Reason: they drive whole builds and exercise
build tooling this diff does not touch.
- `@objectstack/cli` integration tier, declared to CI.

## Acceptance notes

- **`GET /analytics/meta` still lists a host-registered unparsed cube's
measure with its retired `type`**, while the query doors refuse it. This
is pre-existing for any enum-invalid type (`median` read the same at
base). It is reachable only by a host that registers a cube literal
without `CubeSchema`, because every parsing door refuses it first. Not
filed; carrier: none.
- **A stored `analytics_cube` row** read at `GET
/api/v1/meta/analytics_cube/NAME` comes back as stored. Stored rows keep
being read (the runtime gate's D4 asymmetry), and no conversion rewrites
it (pinned at the seam).
  - Re-saving it is refused with the prescription (the write door pin).
- The analytics registry has no metadata read path (objectstack-ai#20965's
measurement), so such a row reaches no query.
  - The HTTP read itself was NOT MEASURED through the route.
- **The ObjectQL envelope for the three moves from `INVALID_FIELD` / 400
to the undeclared-500 tier.** The message is readable and carries the
prescription. This is the tier `dataset-refusal.ts` assigns to a cube
that never met the parse, and the changeset says so.
- A never-declared type (`median`) on the ObjectQL path is now refused
at the resolver in the same tier. Before, it was forwarded to
`executeAggregate`: the auto-bridge refused it, a host's own executor
received it, and `/analytics/sql` echoed `MEDIAN(amount)`.
- `aggregate-bridge-function-vocabulary.test.ts` therefore pins both
seams. The bridge is driven directly through the service's strategy
context, because no cube path reaches it with a non-aggregate method any
more.
- **The `measures.sql` ledger note** (objectstack-ai#20943's, re-pointed by objectstack-ai#21409)
was made false by this diff, as at-tier record `5959409102` ruled. It
was corrected in patch round 2 (`4278601b82`): both runtime expression
branches are gone (the gate's stand-down with objectstack-ai#20965, the raw-SQL
verbatim emit here). What remains for a cube that reaches the service
without meeting the parse is `qualifyAndRegisterJoin` passing a
non-column `sql` through inside the aggregate, measured through
`generateSql`.
- **The prescriptions say "removed … in @objectstack/spec 17.7.0"**,
assuming the next release is a minor (the label is 17.6.0, which is
published). If the next release is cut as a major, those runtime strings
need the new number.
- **Files beyond the claim's list**, all comments or tests that named
the partition or the types, or that the merges made false:
- `service-analytics`: `analytics-service.ts`,
`cube-measure-field-type-door.ts`, `dataset-refusal.ts`, and the tests
`aggregate-bridge-function-vocabulary`,
`caller-member-column-reference-gate`,
`cube-authored-format-granularity`, `field-read-admission-gate` and
`unlisted-refusal-envelope`;
- `lint`: `validate-dataset-measure-aggregates` (source comment and
test).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…, orchestration is Flow, record transitions are the state_machine rule; the XState StateMachineSchema family leaves with it (objectstack-ai#21320) (objectstack-ai#21461)

Fixes objectstack-ai#21320

Part of objectstack-ai#20274

Clause-②: yes (narrowing)

## What this does

The maintainer ruled **D (retire)** on objectstack-ai/cloud#2569 (batch
objectstack-ai#267 item 5, 「其他同意」). This PR carries out that ruling's spec half
through the `spec-property-retirement` playbook.

**`agent.lifecycle` is retired.** It was parsed and never read. No
runtime, in this repository or in cloud, moved an agent through a
declared state or refused an undeclared transition. What it reached for
is already served elsewhere:

- a conversation phase is a **skill** with its own `instructions` and
`tools`, selected by `triggerConditions` (ADR-0064);
- a multi-step process is a **Flow** (ADR-0019);
- a record's status transitions are the `state_machine` **validation
rule** (ADR-0020).

**The XState `StateMachineSchema` family leaves the package with it,**
under the card's scope item 3. The census below shows `agent.lifecycle`
was its last authorable consumer. ADR-0020 implementation note 1 had
kept the file only for this door.

This run **resumed a lost one.** The container restarted mid-flight. The
predecessor's eight WIP commits survived, but its census and gate
results did not. Everything below was re-measured in this run; nothing
from before the restart counts as measured. The last section says what
was found done and what this run finished.

## The retirement kit

- **Tombstone.** `AgentSchema.lifecycle` is now a `retiredKey()`
(`packages/spec/src/ai/agent.zod.ts`). Its prescription names the three
destinations and ends with the house `os migrate meta --from 17`
sentence. `tsc` refuses the key, because its input type is `never`.
- **Form.** The agent form drops its `lifecycle` composite row
(`agent.form.ts`). The four `platform-objects`
`*.metadata-forms.generated.ts` catalogs lose the row's label and help
text in every locale.
- **D2 conversion `agent-lifecycle-removed`.** It runs at step 18 with
`retiredFromLoadPath`, `retiredAfter` 17.6.0 and order 57, and it sits
in identifier order in `MAJOR_18_CONVERSIONS`. It deletes `lifecycle`
from every `agents[]` entry, whatever the key holds, with one notice per
agent. The delete is lossless. An object's ADR-0057 `lifecycle` block
shares the name and is not touched; a pin asserts this.
- **Registration.** `RETIRED_KEYS_BY_MAJOR[18]` gains
`ai/Agent:lifecycle`. `RETIRED_DEFS_BY_MAJOR[18]` gains the five
published defs: `automation/StateMachine`, `StateNode`, `Transition`,
`ActionRef` and `GuardRef`. Each is one entry file, written into the
generated regions by `gen:migration-registry`.
- **D3 entry `agent-lifecycle-retired`.** One entry covers the family.
It carries the judgement no conversion can make: which of the three
destinations each deleted machine meant. Its `STEP18_RATIONALE` fragment
is order 62.
- **Family deletion.** `automation/state-machine.zod.ts` and its test
are deleted. `./automation` stops re-exporting the module.
`StateNodeConfig` leaves the root and `/ai` entries, whose only
structural mention of it was the tombstoned key.
- **Ledger.** The `liveness/agent.json` row `lifecycle` moves from
`experimental` to `dead`, with `verifiedAt` 2026-10-02 and the REMOVED
note. The tombstone keeps the key in the walked shape (the
`rls.priority` precedent). `state-counts/agent.md` is regenerated. The
README's agent row no longer says "autonomy tier experimental": no
`agent` row is `experimental` any more (A6).
- **Generated baselines.** These are regenerated, not hand-edited:
`authorable-surface/{ai,automation}.json` (one new `ai/Agent:lifecycle
[RETIRED]` row and 18 family rows gone), `authorable-defaults`,
`json-schema.manifest` (five defs gone), `api-surface`,
`export-origins`, `declaration-map`, `content/docs/references/**` (the
`state-machine` page is gone) and the strictness-ledger counts.
- **Pins.** `packages/spec/src/ai/agent-lifecycle-retirement.test.ts` is
a `repo`-project test with 14 cases:
- the refusal for every value, with the issue `code`, the `path` and the
prescription;
- the `defineStack` door's ADR-0112 envelope (`STACK_SCHEMA_INVALID` /
422);
  - the stored-row replay and the boot-door before/after;
  - idempotence and load-path retirement;
  - the registration;
  - the family's runtime absence from `./automation`;
- a tree-scoped absence walk over the five roots declared for
`@objectstack/spec#test`, with an anti-vacuity matcher case.
- **Changeset.** `.changeset/21320-agent-lifecycle-retired.md` bumps
`@objectstack/spec` `minor` and `@objectstack/platform-objects` `patch`.
It carries a **BREAKING** banner, the FROM → TO table, the one-line fix,
`Clause-②: yes (narrowing)` and the ADR-0087 `registered` marker naming
both ids.

## The census (measured in this run)

**`StateMachineSchema` family consumers at `origin/main` `c2c21f357c`,
before this branch's change:**

| site | symbol | what it was | disposition |
|---|---|---|---|
| `src/ai/agent.zod.ts:7`, `:341` | `StateMachineSchema` | **the one
authorable consumer**: `AgentSchema.lifecycle` | tombstoned |
| `src/data/validation.zod.ts:187` | `StateMachineValidationSchema` |
the ADR-0020 `state_machine` rule. It is a **different export** (a flat
`{ from: [to] }` table) and never imported the family | unchanged; it is
the prescription's destination |
| `src/api/protocol.zod.ts:2614` | comment | claimed
"`StateMachineSchema` stays authorable on the object". That has been
false since ADR-0020 retired `object.stateMachines` | corrected |
| `src/automation/state-machine.zod.ts:25` | docblock | named the agent
lifecycle as the surviving door | file deleted |
| `src/ui/chart.zod.ts:50` | comment | named `StateMachineSchema` as a
positive control | updated |
|
`migrations/entries/semantic/17.ui-interaction-config-family-retired.ts:38`,
`17.authoring-schemas-strict-unknown-keys.ts:20` | text | historical
witnesses inside released D3 entries | left, because they are dated
records |
| `src/ai/index.ts:47`, `src/index.ts:147` | `StateNodeConfig` re-export
| entry-nameability only; it was mentioned solely through `lifecycle` |
removed |
| `recursive-schema-input-assertions.ts`,
`union-author-message-pins.test.ts`, `type-alias-convention.pin.test.ts`
(778 → 773 pins), `sync-retirement.test.ts`,
`scripts/export-origins.test.ts`,
`scripts/liveness/check-liveness.test.ts` | tests and type probes |
their probes and witnesses were the family or `agent.lifecycle` |
removed or re-pointed (`FlowSchema`, `tool.outputSchema`) |
| `content/docs/automation/workflows.mdx` | `StateMachineConfig` |
**taught** the XState type for record lifecycles | rewritten to the
`state_machine` rule, `os:check` green |

Nothing outside `packages/spec` imports the family: `git grep` over
`packages/**`, `examples/**`, `skills/**` and `content/**` finds no
import.

**`agent.lifecycle` producers and readers (A3):**

- **Readers: 0** outside `packages/spec`. The pattern `.lifecycle` /
`['lifecycle']` / `"lifecycle"` has 62 hits in `packages/**` and
`examples/**`. Every one is an object's ADR-0057 data lifecycle, a
service-registration lifecycle, a schema-migration composition or an
i18n key path. One hit is agent-related: the form-label pin in
`object-lifecycle-panel-echo-decisions.test.ts`, which is re-pointed
(A2).
- **Producers: 0.** `examples/**` contains no agent definition at all,
so its control reading is also 0, and that zero is not a census of agent
authors. Seventeen files outside `packages/spec` name `defineAgent` /
`AgentSchema`, and none of them authors `lifecycle`.
- **objectui at the pin `89cad75d55`:** 0 imports of any family export.
The control `FilterCondition` is found in 44 files. `AgentPreview.tsx`
draws no `lifecycle`.
- **Cloud:** NOT MEASURED here, because this session has no cloud
checkout. The card's cloud zero-reader census (cloud `@3aadd908`) is
attributed, not re-taken.

## Deviations and conflicts (the reviewer should read these)

1. **This PR touches `skills/**`, so it is Tier H.** The claim's file
surface says "No `skills/**` edit"; this breaches it.
- `skills/objectstack-ai/references/_index.md` loses one generated line:
the transitive dependency `automation/state-machine.zod.ts`. Once
`agent.zod.ts` stops importing that file, `check:skill-refs` fails
without that change.
- Any retirement of `agent.lifecycle` causes this, even one that keeps
the family file, because the index is computed from `agent.zod.ts`'s
imports.
- The generated-surface exception (objectstack-ai#11705) does not lift the path. This
PR also edits `packages/spec/scripts/**`, which the exception's co-edit
fence treats as the generator tree. The edits there are a liveness
witness, an export-origins witness, the `undrilled-containers` baseline,
and two corrected comments in `build-skill-references.ts` and
`lib/skill-map-guards.ts`.
- `check-governed-merges.mjs --branch` answers exit 3: **GOVERNED,
landing tier H**.
- Landing therefore needs the maintainer's hand, or an authorized
APPROVED review.
2. **These files are outside the claim's declared file surface.** Each
is a consequence of retiring the family:
- hand-written docs: `workflows.mdx`, `quick-reference.mdx`, the
strictness-ledger prose row and its counts;
- `PROTOCOL_MAP.md`, `llms.txt` and `docs-import-surface.baseline.json`;
   - the spec test, witness and baseline files in the census table;
   - `vitest.repo-tests.json`;
- comment corrections in `api/protocol.zod.ts`, `ui/chart.zod.ts`,
`ai/index.ts`, `index.ts` and `automation/index.ts`.

## Verification

All of this ran on head `b4e1682e1c`, after merging `origin/main`
`53fd35e3e3` through `scripts/pm/os-regen-merge.sh`. That merge includes
objectstack-ai#21431's generated registry entry,
`analytics-row-wildcard-outside-count-refused`. No hunk was
hand-resolved, and `check:migration-registry` is green with nothing to
regenerate.

**Gates.** Every derived gate ran on head `b4e1682e1c`, with each exit
code captured before any pipe:

- **The derived union.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 124 families, and all 124
exited 0. The reconciliation `dispatch-gates --ran` reads: `✓
dispatch-gates --ran: 124 derived famil(ies) accounted for — 124 run, 0
NOT-MEASURED (a DERIVED zero — all 124 recorded an exit code and none of
them is 3).`
- **`check:generated`:** `✓ All 15 generated artifacts are up to date`.
`check:migration-registry` reads `✓ src/migrations/registry.ts is
current (349 semantic, 245 retired-key, 217 retired-def)`.
- **`check:liveness`:** `✓ … state-counts/ is current`. Across the
shards: 989 live, 1 experimental, 1 live-elsewhere, 110 dead, 10
planned.
- **`check:api-surface`:** `@objectstack/spec public API surface +
factory signatures unchanged ✓`, read against the committed snapshot,
which carries the removal.
- **`check-adr-0087-registration --base origin/main`:** `✓ … 1
declared-breaking changeset(s), each carrying an ADR-0087 disposition.`
The arm was read as `[BREAKING+bang+clause-②-narrowing] registered
agent-lifecycle-removed, agent-lifecycle-retired`.
- **`check-changeset-no-major`:** `✓ This diff introduces no major
bump.`
- **`check-empty-changeset`:** `✓ No empty-frontmatter changeset
introduced by this diff`.
- **`check:i18n`:** `check-i18n-bundles: OK (9 package(s) — all bundles
in sync, no undeclared authoring keys).`
- **`check:doc-authoring`:** `✓ doc authoring guard: 17266
customer-facing string(s) … clean`.
- **`check:nul-bytes`:** `check-nul-bytes: OK (… no raw ASCII control
bytes).`

**Tests:**

| command | head | result |
|---|---|---|
| `pnpm --filter @objectstack/spec test` | `b4e1682e1c` | 600 files,
17689 passed, 1 todo |
| `pnpm --filter @objectstack/spec test:repo` | `d472aaffaf` | 50 files,
877 passed |
| `pnpm --filter @objectstack/spec typecheck` | `d472aaffaf` | exit 0;
`check:test-typecheck: OK` |
| `@objectstack/platform-objects` `vitest run` and `typecheck` |
`b4e1682e1c` | 59 files, 949 passed; exit 0 |
| `@objectstack/lint` `src/lint-liveness-properties.test.ts` |
`b4e1682e1c` | 95 passed |
| `@objectstack/dogfood` `test/expression-conformance.test.ts` |
`b4e1682e1c` | 7 passed |

The `d472aaffaf` rows are the merge commit. Its `packages/spec` tree is
byte-identical to `b4e1682e1c`; the only later commit edits one
`platform-objects` test.

**One red was found and fixed in this run.** The `platform-objects`
echo-decisions positive control failed with `expected 659 to be 660`
until the count moved with the retired row.

**Ablation of the tombstone** ran on the committed head through
`scripts/ablation-replace.mjs`, which restores automatically:

- **The mutation.** The anchor ` lifecycle: retiredKey(` became `
lifecycle_ablated: retiredKey(`, a bare delete on the strict schema. The
anchor count went 1 → 0, and the blob went `80b6593b3953` →
`42c1ebe78de2`.
- **The prediction:** turns red.
- **The observation:** `agent-lifecycle-retirement.test.ts` went to **5
failed / 9 passed (14)**. The prescription, walked-shape, tsc-channel,
`defineStack`-envelope and boot-door cases went red. The conversion and
absence cases stayed green, as they should, because they do not depend
on the tombstone.
- **The restore.** The blob after the restore equals HEAD
(`80b6593b3953`), and `git diff HEAD` is empty.
- **The tsc channel.** The pin's `@ts-expect-error` is live: `tsc -p
tsconfig.test.json --listFilesOnly` lists the pin (count 1, and the
control `agent-memory-store-retirement.test.ts` also counts 1), and the
pin carries no debt entry in `test-typecheck-debt.json`.

## `skills/**` readings

- **The changed file.** `skills/objectstack-ai/references/_index.md`
goes from 43 to 42 lines. It is generated, and the change is one deleted
line.
- **The whole package.** The sum of every `SKILL.md` is 4397 lines
before and 4397 after. No `SKILL.md` is touched.

## Acceptance notes

- **`skills/**` teaching.** No text in `skills/**` teaches
`agent.lifecycle` or the XState family (A7).
`skills/objectstack-automation/references/state-machines-and-approvals.md`
teaches the `state_machine` validation rule, which is the prescription's
destination.
- **Historical comments left as written.** These comments still describe
`StateNodeConfig` as one of `defineStack`'s structural mentions: the
nine `scripts/i18n-extract.config.ts` headers,
`scripts/check-entry-nameability.ts:97` and
`scripts/root-entry-type-nameability.pin.test.ts:16`. They are dated
records of objectstack-ai#10868 / objectstack-ai#11350.
- **The authorable-surface anchor.** `authorable-surface.base.json`
still lists the family's rows. Only `gen:authorable-surface-base` writes
that anchor, and the anchor is allowed to lag.
`check:authorable-surface` is green.
- **Historical audit files.** `packages/spec/ZOD_SCHEMA_AUDIT_REPORT.md`
and `DEVELOPMENT_PLAN.md` still name the deleted file.
- **Out-of-repo consumers are NOT MEASURED.** That covers
tenant-authored agents and code outside this repository, cloud included,
that imports the family's exports. The changeset says so, and the
prescription and the D2 replay cover stored agent rows.
- **`os lint`.** It reads the unparsed stack, so it now grades an
authored `agent.lifecycle` `liveness-dead-property`. This was measured
with `lintLivenessProperties` on this tree; the control
`tool.outputSchema` still reads `liveness-experimental-property`. The
parsing doors refuse the key first. The changeset was corrected to say
exactly this.

## Resume record: found done vs finished in this run

- **Found done.** The predecessor's WIP commits held the whole kit:
`b3b1fe8852`, `4729a31154`, `f1fd8ebb5f`, `fbd4e311ea`, `d41227ffd4`,
`9e40a894aa`, `f3f5301451` and `54018d8e6a`, plus a merge.
- **Finished in this run:**
- re-measured the census, and with it the go-ahead to retire the whole
family;
- merged `origin/main` twice through `os-regen-merge.sh` (`42bce96be8`,
`d472aaffaf`);
  - corrected the changeset's `os lint` sentence (`d3240440b9`);
- fixed a red the predecessor missed: the metadata-form catalog's
per-locale positive control in
`object-lifecycle-panel-echo-decisions.test.ts` read 660 and is now 659
(`b4e1682e1c`);
  - ran the whole gate set and the ablation.

## 维护者速读(草稿)

**改了什么**:把 agent 元数据上的 `lifecycle`(对话状态机)退役,改为编写时报错并给出处方(会话阶段用 skill +
`triggerConditions`,多步流程用 Flow,记录状态流转用 `state_machine` 校验规则);随之删掉它唯一还在用的
XState 风格 `StateMachineSchema` 一族导出。表单、四语种表单文案、台账、生成物、文档、迁移登记(D2 转换 + D3
说明)同步。

**为什么改**:裁决 cloud#2569 定 D(退役)。这个键一直是「声明了但没有任何运行时读取」——写了等于没写,对 AI
编写元数据是陷阱;实测本仓与 objectui 零读取零编写。

**风险与代价(含回滚)**:`@objectstack/spec` minor + BREAKING:写了 `lifecycle` 的
agent 会在 parse 时被拒(D2 转换会把存量数据里的该键无损删除);外部若有代码 import
这一族导出会编译失败(仓外未测量)。本 PR 因生成的 `skills/objectstack-ai/references/_index.md`
少一行而成为 Tier H(人合)。回滚即 revert 本 PR。

**席位意见**:(留空)

**你要做的**:审阅通过后在本 PR 上 APPROVE(Tier H),席位随后落地。

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… field is refused at the mint, INVALID_FIELD / 400 (objectstack-ai#21437) (objectstack-ai#21474)

Fixes objectstack-ai#21437
Clause-②: no (narrowing)

Dispatched by the PM claim `5961286080` (PM loop round 2,
`domain:services` seat 2), on triage's grade `5957198328` as amended by
`5958146715`. Session `session_01DiCSbmJrkzNhuEAier4VoJ`.

`inferMeasure` minted the row wildcard `'*'` for an empty prefix (`_sum`
became `{ type: 'sum', sql: '*' }`), and it passed `*`, `*_sum` and the
empty spelling through verbatim. The member-shape gate admits `'*'` as a
column reference, so `SUM(*)` / `AVG(*)` / `COUNT(DISTINCT *)` / `SUM()`
reached the database and `POST /api/v1/analytics/query` answered `500
DATABASE_ERROR` on both strategies. The mint now admits `'*'` only for
the bare `count`. It refuses a source that names no field with
`INVALID_FIELD` / 400, naming the spelling the caller sent, before any
statement is built.

## What changes (`@objectstack/service-analytics`,
`src/analytics-service.ts` only)

- **`INFERRED_MEASURE_SUFFIXES`.** The suffix list `inferMeasure`
iterates is hoisted to one module-level constant. It is exported from
the module only, not from the package index. The enumeration pin
iterates it, so a suffix added later is pinned when it lands.
- **`inferMeasure(key, spelling)`.** For any key but `count`, the source
is the part before the matched suffix, or the whole key when no suffix
matches. A source that is empty or `'*'` is refused. No other path
changes: a non-empty source is minted exactly as before.
- **The envelope.** The refusal is built by `invalidMemberError`, the
constructor the mint's dotted-measure refusal already uses. It carries
`code: INVALID_FIELD`, `status: 400`, `member` (the spelling as sent,
with any `CUBE.` qualifier), `param: 'measures'` and `cube`. That is the
code and status of the missing-field refusal (`assertMeasureFields`).
There is no `field`, because there is no field to name.
- **Both mint sites** pass the request's own entry. They are the ad-hoc
mint in `inferCubeFromQuery` and the augmentation loop in `ensureCube`.
Both run inside `ensureCube`, ahead of `assertCallerMembersResolvable`
and of strategy selection, on `query()` and on the dry run
`generateSql()`.
- **`inferredCallerMeasureSql`**, the gate's input, is exported from the
module only, for the by-construction pin.
- **Unchanged, per the amended ruling:** the gate's `'*'` pass-through.
There is no local copy of `rowWildcardOutsideCount`, no spec export, no
strategy edit and no `filter-normalizer.ts` edit (the file objectstack-ai#21448
holds).

## Measured at the door: `POST /api/v1/analytics/query`

The probe used the runtime dispatcher's composition from
`packages/runtime/src/analytics-json-dimension-door.test.ts`:
`AnalyticsServicePlugin` over a real `ObjectQL` engine and `SqlDriver`
(SQLite), with the dispatcher-plugin route. The ObjectQL cells narrow
`queryCapabilities` to the engine-aggregate path. There are three rows,
amounts 100 / 300 / 1000. The authored cube declares `count` and
`amount_total` only. The probe file was throwaway and is not committed.

| spelling | `origin/main` `713b0fa76`: native SQL | `713b0fa76`:
ObjectQL | this branch: both strategies |
|---|---|---|---|
| `_sum`, `_avg`, `_average`, `_min`, `_max` (ad hoc and authored, bare
and `CUBE.`-qualified) | 500 `DATABASE_ERROR`, rawSql+1 | 500
`DATABASE_ERROR`, aggregate+1 | 400 `INVALID_FIELD`, no read |
| `_count_distinct` | 500 `DATABASE_ERROR`, rawSql+1 | 400
`INVALID_QUERY`, aggregate+1 | 400 `INVALID_FIELD`, no read |
| `*`, `*_sum`, `*_avg`, `CUBE.*` | 500 `DATABASE_ERROR`, rawSql+1 | 500
`DATABASE_ERROR`, aggregate+1 | 400 `INVALID_FIELD`, no read |
| `*_count_distinct` | 500 `DATABASE_ERROR`, rawSql+1 | 400
`INVALID_QUERY`, aggregate+1 | 400 `INVALID_FIELD`, no read |
| `''` (empty spelling) | 500 `DATABASE_ERROR`, rawSql+1 | 500
`DATABASE_ERROR`, aggregate+1 | 400 `INVALID_FIELD`, no read |
| `CUBE.` (qualifier alone) | 403 `PERMISSION_DENIED`, no read | 403
`PERMISSION_DENIED`, no read | 400 `INVALID_FIELD`, no read |
| control `count` | 200, 3 | 200, 3 | 200, 3 |
| control `amount_sum` | 200, 1400 | 200, 1400 | 200, 1400 |
| control `amount_total` (authored) | 200, 1400 | 200, 1400 | 200, 1400
|
| `_`, `__sum` (prefix `_` is a field-shaped name) | 400 `INVALID_FIELD`
(missing field `_`) | same | unchanged |

Every refused cell's message names the spelling sent, for example
"Measure '_sum' on cube 'X' names no field to aggregate: nothing
precedes the suffix '_sum'".

## Scope: one rule, wider than the card's title (declared)

- **The row wildcard source (`*`, `*_SUFFIX`, `CUBE.*`).** The card
names the empty prefix. The added pin ("for every caller-named spelling,
a `'*'` reaches the gate only together with `count`") cannot hold unless
the mint also refuses a `'*'` source: `*` and `*_sum` mint `'*'` under
`sum`, verbatim. That was measured as 500 above. One condition, source
`'*'`, covers it. This is the card's own family (the row wildcard under
a non-`count` aggregate) and needs no second rule.
- **The empty source with no suffix (`''`, and `CUBE.` after the
qualifier strip).** These are bounded in-place fixes, and all four
conditions hold.
1. It is the same defect class: a caller spelling minted into an
aggregate over a source that names no field. `''` answered 500 on
`main`.
  2. The fix is mechanical: the same `source === ''` condition.
  3. The file is held by no other claim (the claim's file surface).
4. It is the same pin file and gate family, with no new verification
surface.

Moved answer, declared in the changeset: `CUBE.` was a 403 from the
member-shape gate and is now the mint's 400, since it names no field
either.
- **Authored members are not this card's.** `CubeSchema` admits any
measure key (`z.record(z.string(), MetricSchema)`). Measured at the spec
`dist` built from `713b0fa76`: a cube declaring measures `_sum` and `*`
parses. `DatasetSchema` refuses a dataset measure named `_sum`
(`invalid_format`, snake_case starting with a letter). A cube that
DECLARES `_sum` hits its own member and never reaches the mint. A pin
serves it (1400).

## Pins: `src/__tests__/caller-measure-no-field-door.test.ts` (new)

The pins use the plugin's own composition over a real `ObjectQL` engine
and `SqlDriver` (SQLite), both auto-bridges live, on the native and
ObjectQL strategies. They follow the precedent of
`cube-measure-field-type-door.test.ts`.

- **Enumeration.** Every suffix in `INFERRED_MEASURE_SUFFIXES` is tested
with an empty prefix, bare and `CUBE.`-qualified, on both strategies, on
an ad-hoc cube and on an authored cube that does not declare it. Each is
refused with `INVALID_FIELD` / 400, `member` equal to the spelling,
`param: 'measures'`, `cube`, no `field`, and the message naming the
spelling. Each run has zero raw statements and zero engine aggregates.
- **Other no-field sources.** `*`, `*` with every suffix, `''`, `CUBE.*`
and `CUBE.` get the same refusal, with no read.
- **Controls.** The bare `count` returns 3, and its dry-run statement is
`COUNT(*)`. `amount_sum` returns 1400 (ad hoc and authored). The
authored `amount_total` returns 1400. A declared `_sum` member is
served, 1400.
- **Dry-run door.** `generateSql` refuses every empty-prefix suffix the
same way.
- **By construction (the added pin).** Over a generated corpus
(qualifier `''` / `CUBE.` / `other.` × prefix `''`, `*`, `**`, a space,
`_`, `amount`, `count` × tail `''` or each suffix),
`inferredCallerMeasureSql` either refuses with the envelope or returns
null or a string. A `'*'` comes back only for `count` and `CUBE.count`,
which `inferMeasure` types `count`. The pin also asserts that the corpus
exercised both arms.

The pins stay in `service-analytics`. `AnalyticsService.query()` /
`generateSql()` are what the dispatcher's `/analytics/query` and
`/analytics/sql` routes call one-to-one. The dispatcher carries a thrown
`code` / `status` to the wire, and the same `INVALID_FIELD` / 400
crossing is already pinned at the route by
`packages/runtime/src/analytics-json-dimension-door.test.ts`. The
throwaway probe above measured the wire answers on this branch. No
runtime file is added, so there is no cross-lane addition.

## Ablations (from the committed state, `node
scripts/ablation-replace.mjs` in wrap mode, restore proven: blob equals
HEAD and `git diff HEAD` is empty)

The subject is imported from `src` (`../analytics-service.js`), so no
`dist` sits on the path.

- **A1: the predecessor mint restored.** The throw is replaced with
`return { label: key, type, sql: source || '*' };`. Predicted: the 8
refusal tests, the 2 dry-run tests and the by-construction pin go red,
and the 9 controls stay green. Observed: **11 failed, 9 passed**. The
enumeration cells answer `DATABASE_ERROR` again (and `INVALID_QUERY` for
`_count_distinct` on ObjectQL), the dry run resolves a statement, and
the by-construction pin reports that `''` reaches the gate as `'*'` but
is not `count`.
- **A2: only the wildcard arm dropped.** The condition `source === '' ||
source === '*'` becomes `source === ''`. Predicted: the empty-prefix
enumeration and the dry run stay green, and the 4 other-no-field tests
and the by-construction pin go red. Observed: **5 failed, 15 passed**,
with "`*` reaches the gate as `'*'` but is not count". So the added pin
catches what the empty-prefix enumeration alone does not.

## Verification (at `b49aba455`, after merging `origin/main`
`49524f690`)

- `pnpm --filter @objectstack/service-analytics typecheck`: clean. `tsc
--listFiles` includes the new test file.
- `pnpm --filter @objectstack/service-analytics test` (the full script):
**172 files passed, 3931 tests passed, 183 skipped**.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 64 commands, and all 64 were run. 63 exited 0 on
their first run. `pnpm check:dual-build-cjs-loads` first exited 3 with
`PREREQUISITE NOT MET` (some packages had no `dist/`). After a full
`turbo run build` (72 tasks, 71 cache hits) it exited 0. `--ran` with
every exit code recorded: "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN".
- `check:adr-0087-registration` judged the changeset
`[BREAKING+bang+clause-②-narrowing]`, `not-required
(no-migration-prescription)`. `check:changeset-no-major` reports no
`major`, and `check:empty-changeset` reports no empty-frontmatter
changeset and no modified one.
- Lint, narrowed and declared as such. eslint `--no-inline-config
--format json` over the two touched `.ts` files reports 2 files, 0
errors and 0 warnings. `eslint.config.mjs` never enables type-aware
linting (no `parserOptions.project`), so this diff cannot move a verdict
on an untouched file. The repo-wide `pnpm lint` is CI's.
- CI's own jobs (Test Core shards, Dogfood, Build Core, the type-check
lanes) were not measured locally.

## Changeset

`.changeset/21437-analytics-measure-names-no-field.md` declares
`@objectstack/service-analytics`: `minor` with the BREAKING banner, the
`Clause-②: no (narrowing)` line, before and after cells, the one-line
fix, and one ADR-0087 disposition (`not-required
(no-migration-prescription)`, every other category ruled out on facts).
Its twin precedent is objectstack-ai#21431's
`.changeset/21409-analytics-row-wildcard-count-only.md`, the
authored-position half of the same rule. It is the same
`minor`-under-launch-window shape with the BREAKING banner and the
`(narrowing)` arm. That one registers a D3 entry because stored
documents need a prescription. This one has no stored shape, so it takes
the `no-migration-prescription` disposition, as the sibling
analytics-door narrowings do (`21267-analytics-order-key-selected.md`,
`21426-native-number-comparand.md`).

## Docs

`content/docs/api/data-api.mdx` ("How to spell a measure") already
states the contract this enforces: the bare `count`, or one of the
object's own field names plus a suffix. The change makes no sentence
there false, so it is untouched. A `skills/**` grep for measure
spellings finds only field-prefixed ones (`amount_sum`, `total_sum`,
`revenue_sum`). None is an empty prefix or `'*'`.

## Acceptance notes

- **Docs drift, not caused here.** `inferMeasure` also strips
`_average`, and with no suffix it sums the whole key. `data-api.mdx`
lists neither. Nothing was made false, so this is noted only (carrier:
none).
- **The console adapter.** objectui's analytics adapter, at the pinned
`.objectui-sha` `89cad75d5`, composes a measure as the value field, an
underscore and the function. A widget with an empty value field would
post `_sum`. That adapter classified the old 500 as `unknown` and
answered with its client-side `aggregateViaFind`. It classifies the new
400 as `rejected` and throws `AnalyticsQueryRejectedError`. This is the
intended direction (loud over plausible numbers), and the changeset
states it. Whether any shipped widget reaches an empty value field was
not measured.
- **The gate's empty-measure skip.** The `measure === ''` skip in
`assertCallerMembersResolvable` no longer sees `''`, because the mint
refuses it first. It is left as is, and the gate is untouched per the
ruling.
- **No dialect cell.** The pin file has no PostgreSQL cell. The refusal
happens before any statement, so it is dialect-free.

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

---------

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

Projects

None yet

2 participants