Repository navigation
fix(runtime)!: the analytics dispatcher faces refuse an unauthenticated caller with 401 UNAUTHENTICATED (#21061) - #21098
Conversation
…-deny floor handleAnalyticsRequest opens with the shared decision, shouldDenyAnonymous, as its first statement: ahead of the service-availability probe and the body validation, in the hoisted form domains/security.ts and domains/ai.ts use. Every analytics face (cube read, SQL, meta) now answers an anonymous caller the dispatcher-wrapper 401 UNAUTHENTICATED (ADR-0056 D2). Unit pin per face: anonymous gets 401, the service is never consulted, a malformed body and an empty slot still get 401; a signed-in member, a system context, the member 404 on an empty slot and the member 400 on a malformed body are unchanged. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
The analytics domain now refuses an anonymous caller before anything else, so the fifteen route-behaviour suites that drove it with no identity (error envelopes, 5xx withholding, entry validation, read-scope refusals, the domain registry bridge) now carry a signed-in caller: an auth slot in the shape resolveExecutionContext reads, or the dispatcher's resolution seam stubbed the way the packages fixtures already do. Only identity is stubbed; every expectation is unchanged. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
… proof and the conformance matrix - showcase-anonymous-deny-surfaces: each analytics face (cube read, SQL echo, meta) is driven anonymously (well-formed and malformed body) and with a signed-in member as the 200 control, and each anonymous denial is classified into the dispatcher-wrapper family; the file claims the new anonymous-deny-analytics row. - authz-conformance: an enforced anonymous-deny-analytics row carrying a GATE_PIN on the domain's shouldDenyAnonymous call, plus the analytics dispatcher-domain key; the key leaves the shrink-only ledger baseline (MAX 33 -> 32) and the matrix header's derived counts move with the row. - platform checklist: access-security.anonymous-deny-surfaces revision 2 gains the analytics variant, its step and its acceptance clause. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…cated caller Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…oor; the matrix row string carries no tracker id The analytics domain's anonymous floor reads ExecutionContext.isSystem, a new elevation read site, so row 51 of the census page gains its anchor and the declared counts move 108 -> 109 (regenerated by the gate's --fix). The new conformance row's summary string drops its tracker id, which check:doc-authoring refuses in string prose. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 26 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 0d20ffe196de5eef01e71c0c6901612cb9ee18bd && git checkout 0d20ffe196de5eef01e71c0c6901612cb9ee18bd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2742e53709b790ee76d2e8d1d07e23aa68de2110 fa039bb8c570623666132956176a92784f767f8b && git checkout -B drift-repro 2742e53709b790ee76d2e8d1d07e23aa68de2110 && git merge --no-ff fa039bb8c570623666132956176a92784f767f8b
node scripts/docs-audit/affected-docs.mjs --json 2742e53709b790ee76d2e8d1d07e23aa68de2110
|
CI red on
|
… pin The new GATE_PIN probe on packages/runtime/src/domains/analytics.ts grew the PROBES table, and authz-probe-blind-spot pins its shape. The census now records it the way the file prescribes: PROBE_TABLE moves 18/13/15 to 19/14/17, MATRIX_HEADER_PROBE_CLAIM moves 18 to 19 together with the matrix header sentence, the analytics file gets its own GATE_PIN row with same-file positive controls, the derivation measures it with the other domain gate pins, and the classified-key pin reads 17 with its comment brought current (39 ledger keys, 7 classified, 32 baselined). Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgments(a) The door —
(b) Pins.
(c) The gate-compelled additions (claim amendment
(d) Changeset ② Semver level
③ Boundary flagsDeviations (os-dev-report
Out-of-scope notes — "carrier: none" judged:
Governed surfaces: none of the touched paths is a register row ( Check-runs on the head (collapsed latest-per-name; converged conclusions only): 35 check-runs on Mergeability: Implemented-by: VERDICT: PASS Generated by Claude Code |
…e table for every measure (objectstack-ai#21044) (objectstack-ai#21128) Fixes objectstack-ai#21044 Clause-②: no (narrowing) ## What changed A configured cube's `max` over a `text` column was served at the cube door (`POST /api/v1/analytics/query`) by the native-SQL strategy, with the column's text, while the same response's `fields[]` declared the measure `number`. The dataset door refuses that pair at compile by the spec table `AGGREGATE_FIELD_TYPE_COMPATIBILITY`, and the cube door consulted nothing. Triage's direction (`5924500811`) is carried out as ruled: the cube door asks the one table, and no second table exists. - **The judgment** (`packages/services/service-analytics/src/cube-measure-field-type-door.ts`, new, beside `measure-result-type.ts`): `assertCubeMeasureFieldTypesAccepted` refuses the first `measures` entry whose aggregate the table refuses for its column's declared type, `INVALID_FIELD` / 400 through `invalidMemberError`, with `member`, `param: 'measures'`, `cube`, `field` and `object` on the error. The verdict is `isAggregateCompatibleWithFieldType`'s; the accepted set the words name is read off the exported table. It judges every row of the table, as the dataset door does since decision batch objectstack-ai#127, with no scope condition on top of it. `count_distinct` keeps its own door (objectstack-ai#20912, `structured-json-dimension-door.ts`), which asks the same row plus the `multiple: true` declaration, so one pair has one verdict and one wording. - **Where it runs** (`analytics-service.ts`): `assertMeasureFieldTypes`, called in `ensureCube` on all three paths (inferred cube, augmented cube, declared cube), right after the objectstack-ai#20807 / objectstack-ai#20912 door and before the `where` gate. That is ahead of `callCtx` and strategy selection, so both strategies see it once and nothing is read before it answers. The same placement covers the dry run (`POST /api/v1/analytics/sql`) and every query `DatasetExecutor` runs through `queryIn`. - **The column description** (`analytics-service.ts`): `withMeasureResultTypes`, applied at the result seam in `queryIn` beside `withDeclaredMeasureFormats`, asks the dataset door's one rule, `measureResultType`, with the cube measure's aggregate and the declared type of the column it reads, and writes only what the rule answers. A `min` / `max` over `date` / `datetime` / `time` is now described `time`. ⛔ No copy of the rule in `buildFieldMeta`: both strategies are untouched. - **`measure-result-type.ts`**: TSDoc only, one paragraph naming the cube door as the rule's second reader. - **`.changeset/21044-cube-measure-field-type-table.md`**: `@objectstack/service-analytics` minor, BREAKING banner, `Clause-②: no (narrowing)`, ADR-0087 `not-required (already-registered dataset-measure-selecting-aggregate-field-type-refused, dataset-measure-aggregate-field-type-refused)`. `check:adr-0087-registration` accepts it. ### The four measured questions of the dispatch - **H1, the card's reading on `main`.** Confirmed through the real dispatcher route at base `2821e9f15b`, SQLite and PostgreSQL 16.13, both strategies (table below). The native face served every refused `min` / `max` pair with the column's text under `fields[]` `number`. Since PR objectstack-ai#21037 the ObjectQL face already answered `400 INVALID_FIELD`, from the engine's aggregate door, after the strategy had begun: the words name the engine's position (`aggregate('…'): aggregations[0].field takes the max of 'note', a declared text field…`), and the error carries no `member`. `sum` over the same text column answered `0` on SQLite on both faces and `500 DATABASE_ERROR` on PostgreSQL; `avg` answered `0` / `500` on the native face and `400` on the ObjectQL face. - **H2, where the door learns the source field type.** From the resolver this file already uses for measure columns: `declaredMemberEntry(cube, member, 'measure')` (the one `withDeclaredMeasureFormats` reads) gives the cube measure, its `sql` is the column when it is a bare identifier, and the type is `sourceFieldMeta(object, column).type`, the base-object declaration the objectstack-ai#20807 / objectstack-ai#20912 door and `compile()` already read. ⛔ No second resolution of a member to a field. A relationship-path column is not judged (the declaration read is the base object's), which is the dataset door's tier. - **H3, where the refusal goes and its code.** In `ensureCube`, as above. The code is `INVALID_FIELD` / 400, not the dataset door's `DATASET_INVALID` / 400, for the reason `dataset-refusal.ts`'s header gives: `DATASET_INVALID` is a verdict about a dataset document, and `/analytics/query` carries none; a verdict about one member the request named is the `INVALID_FIELD` family. It is also the code the cube door's three source-field gates and its objectstack-ai#20912 `count_distinct` door answer, and the code the engine's door already answered for this very pair on the ObjectQL face, so that face's wire code does not move. The dataset door keeps `DATASET_INVALID` at compile and never reaches this door for a pair it refuses. - **H4, `fields[]` for an accepted non-numeric pair.** By `measureResultType`, read at the cube door's result seam without widening the claimed surface: `min` / `max` over a temporal column is `time`; over a `boolean` column the rule declines (three readings disagree), so the producer's `number` stands, which is what SQLite (`1`) and the ObjectQL face on PostgreSQL (`1`) answer. Triage's second sentence, "`buildFieldMeta` stops minting `number` for a non-numeric `min` / `max`", is delivered at the seam both strategies' results leave through rather than inside `buildFieldMeta`, which has no field types to read: a refused pair never reaches a descriptor, a temporal one is re-described `time` by the one rule, and a boolean one keeps `number` by that rule's own verdict. ## Per-row readings, before and after | driver | strategy | measure | pair | before (`2821e9f15b`): status, value, `fields[]` type | after (`d85b700030`) | |:--|:--|:--|:--|:--|:--| | SQLite | native SQL | config `max_note` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | config `min_note` | `min` over note (text) | 200, `"x"`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | config `max_status` | `max` over status (select) | 200, `"won"`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | config `max_opened` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | SQLite | native SQL | config `min_due` | `min` over due_on (date) | 200, `"2026-01-15"`, `number` | 200, `"2026-01-15"`, `time` | | SQLite | native SQL | config `max_flag` | `max` over flag (boolean) | 200, `1`, `number` | 200, `1`, `number` | | SQLite | native SQL | config `max_amount` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | SQLite | native SQL | config `sum_note` | `sum` over note (text) | 200, `0`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | config `avg_note` | `avg` over note (text) | 200, `0`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | config `cd_note` | `count_distinct` over note (text) control | 200, `2`, `number` | 200, `2`, `number` | | SQLite | native SQL | inferred `note_max` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | inferred `amount_max` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | SQLite | native SQL | inferred `opened_at_max` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | SQLite | native SQL | augmented `note_max` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `max_note` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `min_note` | `min` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `max_status` | `max` over status (select) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `max_opened` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | SQLite | ObjectQL | config `min_due` | `min` over due_on (date) | 200, `"2026-01-15"`, `number` | 200, `"2026-01-15"`, `time` | | SQLite | ObjectQL | config `max_flag` | `max` over flag (boolean) | 200, `1`, `number` | 200, `1`, `number` | | SQLite | ObjectQL | config `max_amount` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | SQLite | ObjectQL | config `sum_note` | `sum` over note (text) | 200, `0`, `number` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `avg_note` | `avg` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `cd_note` | `count_distinct` over note (text) control | 200, `2`, `number` | 200, `2`, `number` | | SQLite | ObjectQL | inferred `note_max` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | inferred `amount_max` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | SQLite | ObjectQL | inferred `opened_at_max` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | SQLite | ObjectQL | augmented `note_max` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `max_note` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `min_note` | `min` over note (text) | 200, `"x"`, `number` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `max_status` | `max` over status (select) | 200, `"won"`, `number` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `max_opened` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | PostgreSQL 16.13 | native SQL | config `min_due` | `min` over due_on (date) | 200, `"2026-01-15"`, `number` | 200, `"2026-01-15"`, `time` | | PostgreSQL 16.13 | native SQL | config `max_flag` | `max` over flag (boolean) | 500 `DATABASE_ERROR` | 500 `DATABASE_ERROR` | | PostgreSQL 16.13 | native SQL | config `max_amount` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | PostgreSQL 16.13 | native SQL | config `sum_note` | `sum` over note (text) | 500 `DATABASE_ERROR` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `avg_note` | `avg` over note (text) | 500 `DATABASE_ERROR` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `cd_note` | `count_distinct` over note (text) control | 200, `2`, `number` | 200, `2`, `number` | | PostgreSQL 16.13 | native SQL | inferred `note_max` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | inferred `amount_max` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | PostgreSQL 16.13 | native SQL | inferred `opened_at_max` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | PostgreSQL 16.13 | native SQL | augmented `note_max` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `max_note` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `min_note` | `min` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `max_status` | `max` over status (select) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `max_opened` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | PostgreSQL 16.13 | ObjectQL | config `min_due` | `min` over due_on (date) | 200, `"2026-01-15"`, `number` | 200, `"2026-01-15"`, `time` | | PostgreSQL 16.13 | ObjectQL | config `max_flag` | `max` over flag (boolean) | 200, `1`, `number` | 200, `1`, `number` | | PostgreSQL 16.13 | ObjectQL | config `max_amount` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | PostgreSQL 16.13 | ObjectQL | config `sum_note` | `sum` over note (text) | 500 `DATABASE_ERROR` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `avg_note` | `avg` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `cd_note` | `count_distinct` over note (text) control | 200, `2`, `number` | 200, `2`, `number` | | PostgreSQL 16.13 | ObjectQL | inferred `note_max` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | inferred `amount_max` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | PostgreSQL 16.13 | ObjectQL | inferred `opened_at_max` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | PostgreSQL 16.13 | ObjectQL | augmented `note_max` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | "Before" is base `2821e9f15b`; "after" is this branch's head `d85b700030` (two merges of `main` in, the second carrying objectstack-ai#21098's 401 for an anonymous analytics caller, so the probe signs its caller in). Both were read through the real `dispatcher-plugin` mount of `POST /api/v1/analytics/query`, over `AnalyticsServicePlugin` composed on a real `ObjectQL` engine and `SqlDriver`, by a scratch probe that was deleted after each run. Two rows of a ledger: `note` `x` / `y` (text), `status` `open` / `won` (select), two instants and two days, `flag` `true` / `false`, `amount` `10` / `32`. "Inferred" is an unregistered cube name (the object's), "augmented" a suffix-inferred measure on the configured cube. The same 56 readings were taken again after the first merge of `main` (`0abe2120fb`): no row differs from the head's. Since objectstack-ai#21103 landed (in the second merge) the engine's door also refuses `sum` over a refused type, so on the ObjectQL face the `sum` rows would answer `400` without this change too; this door answers first. ## Pins (red first), the ablation - **Red, on the tree committed as `be5c1a69b2`** (pins only, no fix; base `2821e9f15b`), SQLite and a private PostgreSQL 16.13: - `service-analytics` `src/__tests__/cube-measure-field-type-door.test.ts` (new) and the flipped `native-sql-measure-number-presentation.test.ts`: 16 failed, 16 passed, 1 skipped. Failures: `max_note must not be served: expected { rows: [ { max_note: 'y' } ], …(1) } to be undefined` (native), `max_note: expected undefined to be 'max_note'` (ObjectQL: the engine's refusal carries no `member`), `max_opened is described time: expected 'number' to be 'time'`, `expected the query to be refused, but it resolved` (dry run). - `runtime` `src/analytics-cube-measure-field-type-door.test.ts` (new): 6 failed, 6 passed. Native `max_note` answered `200`; both faces described `max_opened` `number`. The ObjectQL face's `400 INVALID_FIELD` and both faces' `number` controls were green already. - **Fixture triage.** `native-sql-measure-number-presentation.test.ts` (objectstack-ai#20889) read a configured cube's `max` over its `code` text column back as text, as the second half of its "keyed on the declared function, never on the value" control. That pin held exactly the served pair this card refuses, so it is flipped, not deleted: the case now asserts `INVALID_FIELD` / 400 with no statement run, keeps its text-dimension half, and the cube read above it no longer asks for `max_code`. - **Green, at `d85b700030`:** the same files 32 passed, 1 skipped (the PostgreSQL native `max` over `boolean` cell, a named skip: an accepted pair that is a 500 there, see Acceptance notes) and 12 passed. - **Ablation** (the cube door's table check removed), from committed code at `5d69394a60`. Predicted before running, in `progress.log`: service-analytics 12 red (per dialect: the native refusal, the ObjectQL refusal, both inferred-measure cases, the dry run and the flipped objectstack-ai#20889 case), 20 green, 1 skipped; runtime 2 red (the native refusal on both dialects), 10 green, because the engine's door still answers the ObjectQL face's `400 INVALID_FIELD` on the wire. - The mutation went through `scripts/ablation-replace.mjs` (WRAP mode, trap-restored, absolute path): `if (isAggregateCompatibleWithFieldType(aggregate, declared)) continue;` gained `|| String(aggregate) !== 'ABLATION-21044'`, so every pair passes. Anchor 1 to 0, marker 0 to 1, blob `6c7bdce48e43` to `f49be3058c84`. A second `trap` in the outer script restored by absolute path too. - `service-analytics` was rebuilt, and `ablation-dist-preflight.mjs` found the marker in 2 built files (`dist/index.cjs`, `dist/index.js`). - Observed: service-analytics 12 failed, 20 passed, 1 skipped; runtime 2 failed, 10 passed. Exactly as predicted. - Restore: blob after restore `6c7bdce48e43` equals HEAD, `git diff HEAD` empty, whole-tree porcelain 0 lines. Then rebuilt, and `ablation-dist-preflight.mjs --absent`: marker absent from all 6 built files and the tree clean. The pins are green again at both later heads. ## Tests (at `d85b700030`, after `pnpm install --frozen-lockfile` and a full `turbo run build`, 73 tasks) - `@objectstack/service-analytics`, full suite with `OS_TEST_POSTGRES_URL` set (no PostgreSQL cell skipped): 156 files, 3558 passed, 1 skipped (the named cell above). `typecheck` exit 0; `tsc --noEmit --listFiles` lists both touched test files. - `@objectstack/rest`, `src/analytics-*` and `rest-hook-refusal-message-parity`, with PostgreSQL: 20 files, 279 passed. - `@objectstack/runtime`, `src/analytics-*`, `src/dispatcher*`, `src/http-dispatcher*`, `src/domains/analytics*` and the other analytics-route suites: 51 files, 843 passed. `typecheck` exit 0, `check:test-typecheck` holds its ledger (27 files, 190 errors, 68 signatures; the new file adds none). - Not run locally, declared to CI: the whole `rest` and `runtime` suites, and `packages/qa/dogfood` (`Dogfood Regression Gate`). ## Gates (at `d85b700030`, as ONE sequential script under the shared verify lock, each exit code captured before any pipe) - **Derived families:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` names 62. `--ran` reconciles: `62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3)`. All 62 exit 0. `check:dual-build-cjs-loads` and `check:type-check-debt` answered `PREREQUISITE NOT MET` (exit 3) on the first sweep at `0abe2120fb`, which had built only the dependency closure; after a full `turbo run build` (73 tasks) both exit 0, and both are 0 in the sweep at this head. - **The five roster families the derivation marks ⛔ (a roster in a directory this diff touches):** `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing`, `pnpm check:filter-alias-parity`, `pnpm check:route-ledger-census`. All exit 0. - `check:adr-0087-registration` reads the changeset as `[BREAKING+bang+clause-②-narrowing] not-required (already-registered)`. `check-changeset-no-major`: no `major` bump. `check:nul-bytes`: 9734 text files, no raw control bytes. ## ESLint, a declared narrowing (the repo-wide `pnpm lint` is CI's) - Touched files: `eslint --no-inline-config --format json` over the 6 touched `.ts` files at `0abe2120fb` (the two later commits are a merge of `main` and a test-only harness change of 7 lines). From the JSON: 6 files, 0 errors, 0 warnings, 0 ignored. - Population: `eslint.config.mjs`'s `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`, which contains all 6 (none came back ignored). - Invariance: the config enables no type-aware linting (no `parserOptions.project` or `projectService` in any block), and this diff does not touch the config, so no untouched file's verdict can move. ## Beyond the claimed file surface - **The route pin lives in `packages/runtime/src/`, not beside `packages/rest/src/analytics-*.test.ts`.** The cube door, `POST /api/v1/analytics/query`, is mounted by `@objectstack/runtime`'s `dispatcher-plugin`; `RestServer` mounts only `/analytics/dataset/query`, so a pin in `rest` cannot reach this route. The new file is test-only and sits beside the sibling analytics route pins there. `rest`'s analytics suites were run as well (above). - **`sum` / `avg` are judged by the same door.** The claim priced the narrowing as `min` / `max`; the door asks the table for every row, as the dataset door has since decision batch objectstack-ai#127, because a `min` / `max`-only condition would be a second scope on top of the one table, the shape `dataset-compiler.ts` records retiring. Measured, those rows answered `0` on SQLite and `500` on PostgreSQL before (table above), and the changeset prices them. No shipped cube authors either. ## Acceptance notes - **NOT MEASURED: MySQL** (no server here). The door runs before any driver, so its verdict cannot depend on the dialect; the `time` description reads metadata only. - **NOT MEASURED locally: `packages/qa/dogfood`** (`Dogfood Regression Gate` is CI's). The one shipped cube, `examples/app-showcase`'s `showcase_delivery`, declares `count` over `*` and `sum` / `avg` over `estimate_hours` (`Field.number`): every pair accepted. Every other `min` / `max` / `sum` / `avg` in `examples/**` is a dataset measure, which the dataset door already judged. - **NOT MEASURED: the console.** A console widget that sends a suffix-inferred `FIELD_max` / `FIELD_sum` to `/analytics/query` over a refused field now gets `400 INVALID_FIELD`; the sibling repository was not read (dispatch order). - **Relationship-path measures are not judged here, measured at the head:** a configured measure `{ type: 'max', sql: 'account.name' }` over a related `text` field is still served on the native face (`200`, `"zeta"`, `fields[]` `number`, SQLite and PostgreSQL), and `{ type: 'max', sql: 'account.revenue' }` over a related `number` field answers the string `"250.000000000000000000000000000000"` on PostgreSQL's native face under `fields[]` `number`. The ObjectQL face refuses both as a cross-object measure (`400 INVALID_FIELD`). Judging them needs the declaration on the hop's object, which is the hop-object resolution this dispatch fences off (objectstack-ai#20986's sites); the door stands down on a dotted column rather than guess, the dataset door's tier. This is the second position the seat's comment `5924234751` names; reported to the seat, not fixed here. - **PostgreSQL's native face answers `500 DATABASE_ERROR` for `max` over a `boolean` column**, a pair the table ACCEPTS (`function max(boolean) does not exist`); SQLite and the ObjectQL face on PostgreSQL answer `1`. Unchanged by this PR (measured before and after); the boolean pin skips that one cell by name. Reported to the seat. - `main` advanced three commits after the second merge (`a11faeecb3`, `99398542b3`, `53ed3d1093`: objectql's aggregation-filter door, driver-mongodb and the showcase's security set). None touches `service-analytics`, the dispatcher or the spec table; CI and the merge queue read the merged generation. - The private PostgreSQL 16.13 cluster used for every live cell runs on `127.0.0.1` from `/tmp`; it is stopped and its directory removed with this delivery. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21061
Clause-②: no (narrowing)
This PR carries direction 1 of the emergency triage grade on the card, the door half. Direction 2, the security layer treating "no permission set resolved" as no grant, was split by triage to its own
domain:servicescard and is not part of this change.What changed
handleAnalyticsRequest(packages/runtime/src/domains/analytics.ts) now opens with the shared anonymous-deny decision,shouldDenyAnonymousfrom@objectstack/core(ADR-0056 D2). The call is the handler's first statement. It runs before the analytics service is looked up and before the request body is validated, in the hoisted formdomains/security.tsanddomains/ai.tsuse.Every analytics dispatcher face (cube read, SQL echo, meta) now answers a caller without a session
401 UNAUTHENTICATEDin the dispatcher-wrapper envelope. A signed-in caller, an API-key caller and an internal system context are unchanged.dispatcher-plugin.tsis untouched.service-analytics: that package is untouched.Readings (class level: doors, caller classes, codes and statuses)
Stock showcase boot (
pnpm dev -- --fresh), measured privately on the merge base before the fix and on the branch after it. The signed-in caller is a plain member created on the same boot.Pins
packages/runtime/src/domains/analytics-anonymous-deny.test.ts.showcase-anonymous-deny-surfaces.dogfood.test.tsgains the analytics family in the dispatcher-wrapper family.enforcedrow,anonymous-deny-analytics.shouldDenyAnonymouscall,analytics:domains/analytics.ts:anonymous-gate, and the dispatcher-domain keydispatcher-domain:route-ledger.ts:/analytics.authz-ledger-population.baseline.ts: MAX goes from 33 to 32, with a dated note.covers7 to 8, covers keys 15 to 17, gate pins 5 to 6, ledger keys 39 with 7 classified and 32 baselined.access-security.anonymous-deny-surfacesgoes to revision 2 with a history entry. It gains the analytics variant, a step that names the three faces and points at the/analyticsrows ofroute-ledger.tsfor the routes, and an acceptance clause for the ordering and the member control.docs/qa/platform-checklist/README.md"Change" rule followed: fields edited,revisionbumped,historyappended.Ablation (reverse verification, from the committed fix)
The mutation replaced the gate's condition with a never-true comparison against a planted literal (
ablation-replace.mjs, anchor hit x1, blob changed). The restore leg was proven on disk: blob equals HEAD andgit diff HEADis empty.authz-conformance.test.tswent 6 failed / 49 passed, namingSTALE covers: analytics:domains/analytics.ts:anonymous-gateandDEAD PROBE: packages/runtime/src/domains/analytics.ts.ablation-dist-preflightfound the marker present in 2 built files after the rebuild.showcase-anonymous-deny-surfaceswent 9 failed / 46 passed: the 5 anonymous analytics cases, the 3 envelope classifications and the shared code-and-message case. All 5 analytics member controls stayed green.Verification (HEAD
8364f40c4a)3905d4f385. The commits after it change no file underpackages/runtime.tsc --noEmitpluscheck:test-typecheckover the test layer, exit 0.showcase-anonymous-deny-surfaces,showcase-anonymous-denyandauthz-conformancegave 3 files and 114 passed; typecheck exit 0.dispatch-gates --commandsderived 93 families at8364f40c4a. All 93 were re-run there after the last commit, with each exit code written to disk before reading: 92 exit 0 and 1 exit 1.--ranreconciles 93 of 93.8364f40c4a:check-system-context-census, from the new elevation read site, andcheck:doc-authoring, from a tracker id in the new row's summary string.check:platform-checklist, an absent-symbol anchor inareas/identity-auth.json. Control: the same command onorigin/mainb3d7a70864exits 1 with the identical single problem. This branch does not touch that file or the file it anchors.pnpm lint:--format jsonoutput: 21 linted files, 0 errors, 0 warnings, at8364f40c4a.eslint.config.mjsenables no type-aware linting (noparserOptions.project, no typed rules), and this diff touches no lint config or rule input. So it cannot move the verdict on any untouched file.turbo ls --affectedwas not used for any narrowing claim here.File surface beyond the claim, declared
packages/qa/dogfood/test/authz-conformance.test.ts: one GATE_PIN probe and one line in the exact-equality list of classified ledger keys. The ruled "enforced row carrying a gate pin" can only be minted there, and that list must equal the population once the key leaves the baseline.content/docs/permissions/system-context.mdx: one anchor on row 51 (the anonymous-deny seams), plus the declared counts regenerated bycheck-system-context-census --fix(108 to 109). The floor readsExecutionContext.isSystem, and that gate is red without the anchor.authslot answering a session, or the dispatcher's resolution seam stubbed the way the/packagesfixtures already do. Only identity is stubbed; every expectation is unchanged.origin/maininto the branch, so the gate derivation reads a current tree.Acceptance notes
Noted only, not filed:
anonymous-deny.tsdocblock in@objectstack/corenames "the five runtime domains" (ai, meta, security, actions, automation)./packagesand now/analyticsalso hold the floor. The table header inpackages/runtime/src/endpoint-policy.tsis in the same state. Both are documentation drift; carrier: none./analyticsrows ofpackages/runtime/src/route-ledger.tscarry noauthz:declaration. The field is optional and phased, and the classified/actionsand/packagesrows carry none either. Carrier: none.check:platform-checklistis red onmainbecause of theidentity-auth.jsonanchor above. That gate runs on a manual cadence, not per PR. Carrier: none.minorwith the BREAKING banner, the launch-window convention for a door narrowing. ADR-0087 disposition:not-required (no-migration-prescription).Update: head
fa039bb8c5(the census fix)Dogfood Regression Gate (3/3)was red on8364f40c4a. The newGATE_PINprobe grew the conformancePROBEStable, whichauthz-probe-blind-spotpins (PR note5926114686).packages/qa/dogfood/test/authz-probe-blind-spot.census.tsandpackages/qa/dogfood/test/authz-probe-blind-spot.test.ts.PROBE_FILE_CENSUSrow for the analytics domain file (gate pin, blind spot 0) with its controls.5926289415).fa039bb8c5:authzand anonymous-deny dogfood files pass, 149 tests;The "Verification" section above was measured at
8364f40c4a; the runtime sources are unchanged since.Generated by Claude Code