Repository navigation
feat(spec)!: the analytics row wildcard '*' is admitted only where a count consumes it (#21409) - #21431
Conversation
… '*' 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
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: Reviewed at 2026-10-02T16:07Z. Inputs: card #21409 (body, claim ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: 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>
|
CI red on
|
Contract reviewServed-tier: Reviewed at 2026-10-02T16:39Z. A narrow re-review of the one-commit delta over the PASS record ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
… it rewrites nothing Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewed at 2026-10-02T17:18Z. A narrow re-review of the two-commit delta over the PASS record ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…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>
…, 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>
… 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>
Fixes #21409
Clause-②: no (narrowing)
Dispatched by the PM claim
5953981594(PM loop round 1,domain:specseat 1), on the triage direction in the card body (the B answer5952826307to5952467081). Sessionsession_01UtnxvdiN376GF3sgXwAw4d.The row wildcard
'*'is what acountaggregates (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 acountor a column. The measured500 DATABASE_ERRORatPOST /api/v1/analytics/dataset/queryis now a400 VALIDATION_FAILED.What changes (
@objectstack/spec)MetricSchema.sql, under anytypebutcountcustomatsqlDimensionSchema.sqlinvalid_formatatsqlANALYTICS_COLUMN_PATH(the dataset dimension's own)DatasetMeasureSchema.field, under anyaggregatebutcount(or none: aderivedmeasure)customatfieldDatasetDimensionSchema.fieldinvalid_format(since PR #21240)rowWildcardOutsideCount(reference, aggregate)and its refusalrowWildcardOutsideCountRefusal(slot, aggregateKey, aggregate)live inpackages/spec/src/data/analytics-column-reference.ts, beside the column-reference grammar, outside thedatabarrel (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.pattern. They are declared indropped-refinements.baseline.json: the rootsdata/Metricandui/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 apattern, so the published JSON Schema states it.migrations/entries/semantic/18.analytics-row-wildcard-outside-count-refused.ts.registry.tswas regenerated bygen:migration-registry, never edited between markers. There is no D2 conversion: rewriting tocountchanges the figure the author asked for, and only the author can name a column. There is noRETIRED_KEYS_BY_MAJORrow, and noSTEP18_RATIONALEfragment. That fragment is optional, and adding it would be a hand edit toregistry.tsoutside the claimed generated region.spec-changes.jsonand the upgrade guide stay at protocol 17, as for every major-18 entry, and both checks are green.cube-member-sql-expression-retiredanddataset-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.analytics_cubemeasures.sqlanddimensions.sqlwere the notes that pointed the count-only boundary at spec+service-analytics: retire the cube metric typesnumber/string/boolean— they existed to carry a SQL expression, which #20943 retired from a member'ssql#21000. They are re-pointed here.datasetmeasures.fieldstates the narrowing. All three staylive, re-verified 2026-10-02.content/docs/references/ui/dataset.mdxis regenerated: the measurefielddescribe now says"*"is for a count.@objectstack/specminor, with the BREAKING banner, the(narrowing)arm, FROM → TO and one ADR-0087 marker (registered analytics-row-wildcard-outside-count-refused).service-analyticswas 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 apattern,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
ceb4a939b4,analytics-column-reference.tsdeclared the shared grammar, andANALYTICS_COLUMN_REFERENCEadmitted'*'for every member.cube-member-sql-column-reference.test.tspinned'*'on a cube dimension. That pin is now replaced by the refusal, because it pinned exactly the branch removed.superRefinechained on the strict objects, theDatasetSchemaprecedent), because only they are cross-field./metareads. It is served as stored, with the refusal on_diagnostics. Probe throughcomputeMetadataDiagnosticson the built spec: a stored dataset with{ aggregate: 'sum', field: '*' }reads back asvalid: falsewithmeasures.1.field/custom. A stored cube reads back asmeasures.total.sql/customanddimensions.everything.sql/invalid_format. A re-save through the write door is refused at the slot.400 VALIDATION_FAILEDon every query. That includes a query that selects only its healthycount, which answered200before. It fails closed, never a stand-down, and the blast radius is the dataset. The door test pins both selections.analytics_cuberows. Read from code: these never reach the analytics registry.serve.tsfeeds it from the stack definition'sanalyticsCubesonly, and that parse (defineStack) refuses such a cube.Census: no producer (triage's "no producer is known", measured)
ceb4a939b4.git grepof everyfield/sqlvalue spelled'*'overexamples,packages(fixtures included),content,skills,apps,scriptsanddocsfound 173 hits. Each was read in its enclosing object literal: 154 under acount. The other 19 are QueryAST aggregations (function: 'sum', field: '*'in objectql conformance tests, which is not one of the three positions), comments, and strategy-levelmethod: 'count'literals. Zero sit at a non-count cube measure, a cube dimension or a non-count dataset measure..objectui-shapin89cad75d55. Read-onlygit grepat the pin found zerofield/sqlvalues spelled'*'. Lit controls: 51aggregate: 'sum', 438field: 'amount'. A'*'scan of the 302 files mentioningaggregatefound onlyobjectName: '*'bus events, query-builder'*'and i18n required marks. None is a dataset or cube slot.The door cell: 500 → 400
packages/rest/src/analytics-dataset-row-wildcard-door.test.tsdrives the real route over a realObjectQLengine with a better-sqlite3SqlDriver. It usesAnalyticsServicePlugin's own composition, once per strategy, with read counters proving which strategy answered.ceb4a939b4, dist verified free of the new predicate):Tests 20 failed | 4 passed (24).{"error":"Internal server error","code":"DATABASE_ERROR"}: expected 500 to be 400. That held forsum,avg,min,maxandcount_distinctover'*', on both strategies.200.Tests 42 passed (42)(this file's 34 plus the neighbouringanalytics-16019-driver-declared-fault.test.ts's 8).VALIDATION_FAILEDwith the issue atmeasures.2.field(custom).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).Tests (final union at
60644d73d9, after themainmerge)pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2gaveTest Files 601 passed (601),Tests 17647 passed | 1 todo.src/data/analytics-row-wildcard-count-only.test.ts(31 cases: every non-countAggregationMetricTypeandAggregationFunctionoption, everyDimensionType, thederivedcase, the controls,CubeSchemaand theanalytics_cubedoor,defineCube,DatasetSchemaand thedatasetdoor,defineStackwith 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.tsgaveTests 42 passed (42).934b70a2db; the incomingmaincommits 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.cube-member-inner-name-retirement,cube-refresh-key-retirement,step18-rationale-merge,liveness/evidence,liveness/proof-registry):131 passed.pnpm --filter @objectstack/spec typecheckandpnpm --filter @objectstack/rest typecheck: exit 0.Ablation (one-shot, not kept)
From the committed fix, through
scripts/ablation-replace.mjs,rowWildcardOutsideCountwas made to answerfalse(anchor 1 → 0, marker 0 → 1, blob2bb692602dc8→4bdfba658269). The new spec file went19 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 withgit checkout HEAD --: blob equals the HEAD blob andgit diff HEADis empty, under atrapon EXIT/INT/TERM. The spec suite imports the source by relative path, so nodist/sits on its resolution path.Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths, merge base39a912ea7) derived 115 families. I ran all 115, and--ranreconciles 113 run green, 2 NOT MEASURED (exit 3, prerequisite), 0 UNRUN:pnpm check:dual-build-cjs-loads: needs every package'sdist, which means a whole-repo build.pnpm check:type-check-debt: its--re-measurebuilds the ledgered packages' closure itself, and that build passed the 300 s per-gate cap.Lint & Repo Gatesruns them.check:skill-examplesfirst exited 3 (client-react unbuilt). After buildingclientandclient-reactit exited 0 (259 prose examples type-check).pnpm --filter @objectstack/spec check:generatedpasses all 15 artifacts after the merge. The only stale artifact before wascontent/docs/references/**, regenerated withgen:docs.node scripts/pm/check-widening-tells.mjs --declaration no --diff(merge-base diff) exited 0 with no widening tell. It stated two silences: therowWildcardOutsideCountRefusal(lines name an imported factory it does not resolve. The predicate is not exported from any published entry (check:api-surfacegreen, artifacts byte-identical), so the arm isno (narrowing), as triage wrote.eslint.config.mjslints**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}minusNEVER_LINTED, and all 8 changed.tsfiles fall inside it.--format jsonread back 8 files, 0 errors, 0 warnings.parserOptions.project, no typed rules), so this diff cannot move a verdict on an untouched file.pnpm lintis CI's.Serial
dropped-refinements.baseline.jsonandregistry.ts. I mergedmainthroughscripts/pm/os-regen-merge.sh.measuredcounts only. It was not text-merged: I tookmain's file and re-declared this branch's 12 sites, and the counts were recomputed from the entries.gen:schemathen validated the ledger against the tree.gen:migration-registryreproduced the auto-merged region byte-identically. 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'sagent-memory-store-retired-and-limits-requiredentry is present at HEAD.main.Acceptance notes (not filed)
service-analyticsinferMeasureturns 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 admitsinferredSql === '*'. Read from code only, NOT MEASURED at a door, and outside this card's no-strategy-edit surface. Carrier: thedomain:serviceslane; no carrier named.deriveddataset measure'sfieldis 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.number/string/boolean— they existed to carry a SQL expression, which #20943 retired from a member'ssql#21000's enum retirement is untouched.AggregationMetricTypenumber/string/booleanare still covered by the predicate's "anything but count" for as long as they exist.Generated by Claude Code