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 |
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