Skip to content

fix(runtime)!: the analytics dispatcher faces refuse an unauthenticated caller with 401 UNAUTHENTICATED (#21061) - #21098

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21061-analytics-anonymous-deny
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21061-analytics-anonymous-deny

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

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:services card and is not part of this change.

What changed

handleAnalyticsRequest (packages/runtime/src/domains/analytics.ts) now opens with the shared anonymous-deny decision, shouldDenyAnonymous from @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 form domains/security.ts and domains/ai.ts use.

Every analytics dispatcher face (cube read, SQL echo, meta) now answers a caller without a session 401 UNAUTHENTICATED in the dispatcher-wrapper envelope. A signed-in caller, an API-key caller and an internal system context are unchanged.

  • ⛔ No second gate at the dispatcher mount: dispatcher-plugin.ts is untouched.
  • ⛔ No zero-set admission rule copied into 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.

face caller before after
cube read unauthenticated 200 401 UNAUTHENTICATED
cube read signed-in member 200 200
SQL echo unauthenticated 200 401 UNAUTHENTICATED
SQL echo signed-in member 200 200
meta unauthenticated 200 401 UNAUTHENTICATED
meta signed-in member 200 200
cube read, malformed body unauthenticated 400 VALIDATION_FAILED 401 UNAUTHENTICATED
cube read, malformed body signed-in member 400 VALIDATION_FAILED 400 VALIDATION_FAILED
SQL echo, malformed body unauthenticated 400 VALIDATION_FAILED 401 UNAUTHENTICATED
SQL echo, malformed body signed-in member 400 VALIDATION_FAILED 400 VALIDATION_FAILED
control: record door unauthenticated / member 401 / 200 401 / 200

Pins

  • Runtime unit pin, per face: packages/runtime/src/domains/analytics-anonymous-deny.test.ts.
    • Anonymous callers get 401: both shapes the dispatcher produces, an unresolved context and the guest envelope.
    • The service is never consulted: neither the slot lookup nor any service method runs.
    • A malformed body still gets 401, and an empty slot still gets 401.
    • Signed-in control: the member is served 200 with their own execution context; a system context passes; an empty slot still answers a member the 404; a malformed member body still reaches the validator's 400.
  • Dogfood: showcase-anonymous-deny-surfaces.dogfood.test.ts gains the analytics family in the dispatcher-wrapper family.
    • Each face is driven anonymously, with a well-formed body and with a malformed one.
    • A signed-in member is the 200 control, and the member's malformed body is still a 400.
    • Each anonymous body is classified into exactly one envelope family. The file claims the new matrix row.
    • No pin title states a request: titles and seam labels name the face.
  • Conformance matrix: a new enforced row, anonymous-deny-analytics.
    • It covers a GATE_PIN key on the domain's shouldDenyAnonymous call, analytics:domains/analytics.ts:anonymous-gate, and the dispatcher-domain key dispatcher-domain:route-ledger.ts:/analytics.
    • That key leaves the shrink-only authz-ledger-population.baseline.ts: MAX goes from 33 to 32, with a dated note.
    • The header figures the row moves are updated: probes 18 to 19, named files 13 to 14, rows 51 to 52, rows with covers 7 to 8, covers keys 15 to 17, gate pins 5 to 6, ledger keys 39 with 7 classified and 32 baselined.
  • Checklist: access-security.anonymous-deny-surfaces goes to revision 2 with a history entry. It gains the analytics variant, a step that names the three faces and points at the /analytics rows of route-ledger.ts for the routes, and an acceptance clause for the ordering and the member control. docs/qa/platform-checklist/README.md "Change" rule followed: fields edited, revision bumped, history appended.

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 and git diff HEAD is empty.

  • Source-resolved legs (no build):
    • The runtime unit pin went 18 failed / 11 passed. Every anonymous case was red; every signed-in control stayed green.
    • authz-conformance.test.ts went 6 failed / 49 passed, naming STALE covers: analytics:domains/analytics.ts:anonymous-gate and DEAD PROBE: packages/runtime/src/domains/analytics.ts.
  • dist-resolved leg (dogfood):
    • ablation-dist-preflight found the marker present in 2 built files after the rebuild.
    • showcase-anonymous-deny-surfaces went 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.
    • On the restore leg, after a rebuild, the marker was absent from all 6 built files and the tree was clean.
  • Direction: red as predicted, in every leg.

Verification (HEAD 8364f40c4a)

  • Runtime:
    • Full suite: 301 files, 5029 passed, 5 skipped, run at 3905d4f385. The commits after it change no file under packages/runtime.
    • Typecheck: tsc --noEmit plus check:test-typecheck over the test layer, exit 0.
  • Dogfood: showcase-anonymous-deny-surfaces, showcase-anonymous-deny and authz-conformance gave 3 files and 114 passed; typecheck exit 0.
  • Gates: dispatch-gates --commands derived 93 families at 8364f40c4a. 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. --ran reconciles 93 of 93.
    • Two were red on an earlier pass and are fixed in 8364f40c4a: check-system-context-census, from the new elevation read site, and check:doc-authoring, from a tracker id in the new row's summary string.
    • The one red predates this branch: check:platform-checklist, an absent-symbol anchor in areas/identity-auth.json. Control: the same command on origin/main b3d7a70864 exits 1 with the identical single problem. This branch does not touch that file or the file it anchors.
  • Lint, a proven narrowing rather than pnpm lint:
    • Population read from eslint's own config: all 24 changed paths were passed explicitly. eslint ignored the 3 non-TS paths by configuration and linted 21.
    • Count read from the --format json output: 21 linted files, 0 errors, 0 warnings, at 8364f40c4a.
    • Invariance: eslint.config.mjs enables no type-aware linting (no parserOptions.project, no typed rules), and this diff touches no lint config or rule input. So it cannot move the verdict on any untouched file.
  • Scope note: turbo ls --affected was 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 by check-system-context-census --fix (108 to 109). The floor reads ExecutionContext.isSystem, and that gate is red without the anchor.
  • Fifteen existing runtime suites drove the analytics route with no identity, as the smallest context that compiled. They now carry a signed-in caller: an auth slot answering a session, or the dispatcher's resolution seam stubbed the way the /packages fixtures already do. Only identity is stubbed; every expectation is unchanged.
  • One merge of origin/main into the branch, so the gate derivation reads a current tree.

Acceptance notes

Noted only, not filed:

  • The anonymous-deny.ts docblock in @objectstack/core names "the five runtime domains" (ai, meta, security, actions, automation). /packages and now /analytics also hold the floor. The table header in packages/runtime/src/endpoint-policy.ts is in the same state. Both are documentation drift; carrier: none.
  • The /analytics rows of packages/runtime/src/route-ledger.ts carry no authz: declaration. The field is optional and phased, and the classified /actions and /packages rows carry none either. Carrier: none.
  • check:platform-checklist is red on main because of the identity-auth.json anchor above. That gate runs on a manual cadence, not per PR. Carrier: none.
  • The changeset is minor with 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)

  • What the head commit fixes: Dogfood Regression Gate (3/3) was red on 8364f40c4a. The new GATE_PIN probe grew the conformance PROBES table, which authz-probe-blind-spot pins (PR note 5926114686).
  • Two more files beyond the claim's surface: packages/qa/dogfood/test/authz-probe-blind-spot.census.ts and packages/qa/dogfood/test/authz-probe-blind-spot.test.ts.
    • They take the table shape 18 / 13 / 15 → 19 / 14 / 17, the header claim 18 → 19, and a PROBE_FILE_CENSUS row for the analytics domain file (gate pin, blind spot 0) with its controls.
    • No assertion is weakened. They are declared on the card in claim amendment 2 (5926289415).
  • Verification at fa039bb8c5:
    • the four authz and anonymous-deny dogfood files pass, 149 tests;
    • the dogfood typecheck exits 0;
    • CI on this head: 33 success, 2 skipped, 0 red.
      The "Verification" section above was measured at 8364f40c4a; the runtime sources are unchanged since.

Generated by Claude Code

claude added 6 commits October 1, 2026 04:44
…-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>
…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 1 documentable anchor(s).

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

  • content/docs/permissions/system-context.mdx (via handleAnalyticsRequest (symbol, a top-level function))
What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2742e53709b790ee76d2e8d1d07e23aa68de2110 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0d20ffe196de5eef01e71c0c6901612cb9ee18bd — the merge of head fa039bb8c570623666132956176a92784f767f8b into base 2742e53709b790ee76d2e8d1d07e23aa68de2110, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2742e53709b790ee76d2e8d1d07e23aa68de2110 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI red on 8364f40c: Dogfood Regression Gate (3/3), this PR's own failure, fix in progress

domain:cli seat · session_01VvcEokUG1tvVxkceYfR5XB · 2026-10-01T06:38Z

  • The failing check: job 110249283154, packages/qa/dogfood/test/authz-probe-blind-spot.test.ts (2 assertions).
  • The cause: that census pins the conformance PROBES table's shape and the matrix header's probe count. This PR's new GATE_PIN probe moved both: entries 18 → 19, files 13 → 14, keys 15 → 17, header 18 → 19.
    • It is not a flake and not inherited from main. The file was outside the dev's local run.
  • The fix: the dev updates the census the way its own docblock prescribes, with no weakened assertion, and pushes one commit to this branch. The claim's surface is amended for that file.
  • The contract review waits for the new head and judges it there. Nothing queues until that head is green.

Generated by Claude Code

… 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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

① Derived judgments

(a) The door — handleAnalyticsRequest, packages/runtime/src/domains/analytics.ts.

  • First statement: yes. The net diff adds shouldDenyAnonymous({ userId: ec?.userId, isSystem: ec?.isSystem, method }) as the first executable statement of the handler body, ahead of deps.getService(context, CoreServiceName.enum.analytics) (the availability probe) and ahead of assertAnalyticsQueryBody (the body validation). The refusal is deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }) — the dispatcher-wrapper envelope, the same form domains/actions.ts, automation.ts and packages.ts answer and the same constants security.ts and ai.ts use.
  • Every face the dispatcher routes there answers 401 UNAUTHENTICATED to a caller without a session: the handler body is the single convergence point for POST /analytics/query (cube read), POST /analytics/sql (SQL echo) and GET /analytics/meta (meta). The three mounts in dispatcher-plugin.ts (lines 1178, 1187, 1196) go through dispatcher.dispatch(...), and the legacy HttpDispatcher.handleAnalytics (http-dispatcher.ts:2124) delegates to the same function. The gate runs before subPath and method are inspected, so an unknown sub-path is also refused before it could fall through to handled: false.
  • Signed-in member, API-key caller, system context: unchanged. The gate reads only userId, isSystem, method; a session yields userId, a verified API key yields ApiKeyPrincipal.userId (packages/core/src/security/api-key.ts:131), and isSystem passes. Below the gate nothing moved except two comment lines. One caveat for the record: the API-key class is unchanged by construction of the shared decision; neither the unit pin nor the dogfood family drives an API-key caller, so the PR body's "API-key caller unchanged" is a reading of the decision, not a measured control.
  • No second gate at the mount: dispatcher-plugin.ts is not in the diff. Nothing copied into service-analytics: that package is not in the diff, and service-analytics/src/plugin.ts registers no routes of its own.
  • method passed (the actions / automation / packages form, not the security / ai form): harmless. shouldDenyAnonymous answers false for OPTIONS; the handler then consults getService and, matching no face, returns handled: false — the same answer whether or not an analytics service is installed, so OPTIONS fingerprints nothing and serves nothing. No OPTIONS route is mounted for /analytics (post / get / post only) and http-dispatcher.ts carries no OPTIONS handling, so the branch is reachable only through an adapter catch-all and is inert there.
  • Faces outside this handler: the only other analytics door is POST /api/v1/analytics/dataset/query in @objectstack/rest (rest-server.ts, registerAnalyticsEndpoints), which opens with this.enforceAuth — already gated, flat family, and the matrix row deliberately leaves its rest-family key in the baseline. No other mount exists: the client SDK's analytics surface and metadata-protocol's discovery entry are advertisements, not doors; MCP is never anonymous. Reach this PR leaves open at the analytics doors for the unauthenticated member of the class: none. The signed-in zero-set caller and the picker context remain admitted behind the door — that is security(plugin-security): a non-system caller who resolves no permission set is admitted to every object and read with no row scope; an empty set list grants by absence instead of answering the deny baseline #21079 by the triage split, not reach this PR claims to close.
  • Governing texts: ADR-0056 D2's "Anonymous + no publicSharing ⇒ 401/empty, never unscoped rows … mirror the change in the analytics read-scope path" is met at the door by the 401 arm. ADR-0090 D9 holds: a guest-bound object stays unreachable through analytics exactly as it is through the /data record doors, which answer the same caller 401 on the same boot; the guest position's reach stays with the explicitly declared public surfaces.

(b) Pins.

  • Runtime unit pin packages/runtime/src/domains/analytics-anonymous-deny.test.ts (new): describe.each over the three faces, each against two anonymous shapes (an unresolved context, and the guest envelope — principalKind: 'guest', positions: ['guest'], no userId). Per face: handled: true, status 401, error.code UNAUTHENTICATED, error.httpStatus 401, the message, and getService / resolveService / query / generateSql / getMeta never called; a malformed input still 401; an empty slot still 401 with getService never called. Controls: a member is served 200 with the original body forwarded by identity and the member's own context forwarded; a system context passes; an empty slot still answers a member handled: false; a malformed member body still reaches VALIDATION_FAILED. The envelope is built with the real apiErrorResponse, so error.code is read where the wire carries it. Would red without the fix: yes by construction — without the gate the handler calls getService first and serves the anonymous contexts a 200, a 404 or a 400. The dev's source-leg ablation (18 failed / 11 passed, every anonymous case red, every control green) is consistent with that reading; not re-run here.
  • Dogfood showcase-anonymous-deny-surfaces.dogfood.test.ts: // authz-row: anonymous-deny-analytics added; ANALYTICS_FACES drives the three literal wire paths anonymously, with a well-formed and a malformed body for the two body-carrying faces; memberToken is the 200 control and the member's malformed body the 400 control; the three faces join the dispatcher-wrapper group of the same-code-same-message case and DENIED_SEAMS (owner runtime domains/analytics.ts, family dispatcher-wrapper). Would red without the fix: yes — the card's and the PR's class-level readings say every face answered the unauthenticated caller 200 on the stock boot, and the dev's dist-leg ablation (9 failed / 46 passed, all five member controls green) agrees. Labels name the face, never a request.
  • Matrix row anonymous-deny-analytics, state: 'enforced', proof the dogfood file, covers: ['analytics:domains/analytics.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/analytics']. The GATE_PIN probe in authz-conformance.test.ts mints the first key only while shouldDenyAnonymous( is present in domains/analytics.ts (a regex over the source), so deleting the gate reds the row STALE and the probe DEAD PROBE — the dev's source-leg ablation (6 failed / 49 passed, naming both) matches. The key dispatcher-domain:route-ledger.ts:/analytics leaves authz-ledger-population.baseline.ts; LEDGER_POPULATION_BASELINE_MAX 33 → 32 with a dated note. The matrix header figures (19 probes / 14 files; 39 keys = 7 classified + 32 baselined; 44 of 52 rows without covers; 8 rows / 17 keys; 6 gate pins; 38 of 45 enforced rows in-resolver) reconcile with the diff, and the exact-equality classified list in the test now holds seven keys.
  • Checklist item access-security.anonymous-deny-surfaces: revision 1 → 2, a history entry dated 2026-10-01 with ref: "#21061", one new step naming the three faces and pointing at the /analytics rows of route-ledger.ts for the route spellings (no request recipe), the envelope-family clause extended to the three faces, one new acceptance clause (anonymous 401 including the malformed body; member 200, and 400 on the malformed body), the surface list and refs extended. The README "Change" rule holds: fields edited, revision bumped, history appended.

(c) The gate-compelled additions (claim amendment 5926076065, plus the one the head's CI compelled after it).

  • authz-conformance.test.ts: one GATE_PIN probe in PROBES (the same shape as the /packages probe directly above it) and one line in the classified-key list. Compelled: a matrix covers key can only be minted by a probe in that table, and the classified list is a toEqual over the whole population, so it moves whenever a key leaves the baseline. Nothing else in the file changes.
  • content/docs/permissions/system-context.mdx: row 51 gains packages/runtime/src/domains/analytics.ts#handleAnalyticsRequest; the census figures move 108 → 109 (elevation reads), 114 → 115 (property reads), 105 → 106 (behaviour-bearing), 46 → 47 (files), 91 → 92 (symbols), "8 of 47". Compelled: the new ec?.isSystem read is an elevation read in a file the page did not anchor, and check-system-context-census (lint.yml lines 383-384, inside Lint & Repo Gates) is red without it. The regenerated count is right by arithmetic — one new file, one new symbol, one new read in a behaviour-bearing row — and the gate that re-measures it on the merge ref is green on the head (below).
  • Fifteen runtime suites: all fifteen diffs read. Thirteen add the same SIGNED_IN_AUTH constant (an auth slot whose api.getSession answers a session) and thread it through the harness's getService / getServiceAsync or its boot({...}) service map; domain-handler-registry.test.ts and http-dispatcher.test.ts instead stub timedResolveExecutionContext or pass AUTHED_CALLER(), the move the /packages fixtures already make. Every expect line is byte-identical in every file; the only non-identity edits are the explanatory comments. Identity only — holds.
  • The census (the fourth item, compelled by Dogfood Regression Gate (3/3) on 8364f40c4a; declared on the PR in note 5926114686 and on the card in claim amendment 2, 5926289415): one fast-forward commit fa039bb8 on parent 8364f40c4a, touching only authz-probe-blind-spot.census.ts (+28 / −4) and authz-probe-blind-spot.test.ts (+10 / −9), read in full. PROBE_TABLE 18 / 13 / 15 → 19 / 14 / 17 — the derived table CI itself printed in the failure; MATRIX_HEADER_PROBE_CLAIM 18 → 19, matching the header sentence the first commit already moved; a PROBE_FILE_CENSUS row for domains/analytics.ts with kinds: ['GATE_PIN'], population: 1, reachable: 1, blindSpot: 0, controls: { 'shouldDenyAnonymous(': 1, handleAnalyticsRequest: 3 } — every figure re-counted here against the head (^export async function handle[A-Za-z]+Request x1, handleAnalyticsRequest x3, shouldDenyAnonymous( x1); the derivation's domains array gains the analytics triple so derived.files and the census name the same file set; the test's toBe(15) → toBe(17) with its comment brought to 39 keys / 7 classified / 32 baselined. Compelled: the census exists to pin the PROBES table's shape, and the ruled gate pin grew that table. Not weakened: no assertion is removed, skipped or loosened — derived.table toEqual PROBE_TABLE, the header-equals-table and header-equals-entries pins, and the per-file population / reach / control pins are untouched, and toBe stays toBe. The blind-spot totals are unchanged, as the row's blindSpot: 0 requires.

(d) Changeset .changeset/21061-analytics-anonymous-deny.md. '@objectstack/runtime': minor, a fix(runtime)!: title, Clause-②: no (narrowing), exactly one ADR-0087 marker not-required (no-migration-prescription), and a **BREAKING**: shipped as minor under the launch-window convention banner. Level and banner are right: AGENTS.md's changeset rule reads (narrowing) as BREAKING, scripts/check-changeset-no-major.mjs (the launch-window guard, run by Check Changeset) refuses major, and the banner-plus-disposition form is the one the sibling door-narrowing changesets carry (20280, 20546, 20671, 20740, 20807). The category is in the gate's CATEGORIES, and the body carries no FROM → TO migration section for the prescription detector to contradict — its one remedy sentence (send the call with a session, bearer token or API key) is caller guidance, not a consumer code rewrite. Accuracy: every sentence checks against the diff (first statement; before the probe; before the validator; member, API-key and system unchanged; the member's 404 for an empty slot unchanged; object admission and row scope untouched). Disclosure: doors, caller classes, codes, statuses and the public wrapper shape only; no request recipe; no private reading quoted.

② Semver level

minor on @objectstack/runtime carrying the BREAKING banner — right. Clause-②: no (narrowing) — right: no key is added to a published payload; the accept set of a public door narrows (an unauthenticated caller the three faces answered is now refused), which is the breaking arm the launch-window convention ships as minor with the banner and the ADR-0087 disposition as the carriers. The only non-test source change is packages/runtime/src/domains/analytics.ts; no other published package moves, so one changeset is the whole declaration. Check Changeset: success on the head.

③ Boundary flags

Deviations (os-dev-report 5926053187), each read against the diff:

  1. File surface beyond the claim (authz-conformance.test.ts, system-context.mdx): amended on the card by the seat (5926076065); judged compelled in ① (c). Clear.
  2. Fifteen runtime suites carrying a signed-in caller: amended on the card; identity-only verified file by file in ① (c). Clear.
  3. One merge of origin/main (3905d4f385), no force-push: ordinary; the branch is unshared. Clear.
  4. method passed to shouldDenyAnonymous: judged harmless in ① (a). Clear.
  5. Ablation by a planted never-true literal rather than deleting the call line: sound given the dist-marker uniqueness problem it names; the restore was proven on disk per the report. Not re-run here. Clear.
  6. A pid file briefly written to the container root, moved within a minute: nothing of the kind is in the diff. Clear.
  7. Lint as a proven narrowing over the 24 paths rather than pnpm lint: the invariance argument (no type-aware rules, no rule input touched) is sound, and Lint & Repo Gates runs the full lint on the merge ref — green on the head. Clear.
  8. Private boot under the invite_only posture, member created through the admin create-user door: a measurement detail; no private reading is quoted anywhere public. Clear.
  9. Not declared in the report, surfaced by CI: the dev's local dogfood run covered three files, not the blind-spot census, so Dogfood Regression Gate (3/3) went red on 8364f40c4a (2 failed / 477 passed in that shard: the PROBES table still has the shape this census was measured against — {19,14,17} vs the pinned {18,13,15} — and the matrix header probe count EQUALS the table — 19 vs 18). Not red on origin/main, whose census and matrix header are self-consistent at 18 / 13 / 15 — PR-caused. Closed by the commit on the new head, judged in ① (c). One housekeeping flag, not a finding: the PR body's "Verification" section still names HEAD 8364f40c4a and its "File surface beyond the claim" list does not carry the two census files; the declaration lives in PR note 5926114686 and card amendment 5926289415. The owning seat should fold both into the body before landing so the body and the card agree.

Out-of-scope notes — "carrier: none" judged:

  • The anonymous-deny.ts docblock ("the five runtime domains") and the endpoint-policy.ts table header (/meta, /ai, /security): documentation drift in @objectstack/core and @objectstack/runtime, outside the claim's file surface; the docblock itself defers to the mechanical ratchet, which this PR extends. Carrier: none — right. Noted, not filed — right under Prime Directive chore: version packages #10: not a defect, a contract violation or an authoring trap.
  • The check:platform-checklist red on identity-auth.json: not "carrier: none". The auth-plugin.ts#twoFactor anchor was re-pointed on origin/main by c2ec2986ac (docs(qa): re-point the two-factor-disable mfa_required anchor at a declaration the resolver reads #21027, committed 2026-10-01T05:33Z) — before the PR head (05:54Z) and before the report (06:33Z). The dev's control ran against the merge base b3d7a70864, which was already stale by then (the report itself says origin/main was ahead and was not chased). The note is moot on current main, the gate is manual-cadence by decision (lint.yml lines 3096-3102), and nothing follows for this PR; recorded here so the note is not read as an open finding.
  • The route-ledger /analytics rows carry no authz:: the field is optional and incremental by its own docblock ("a blank one changes no behaviour and is not a defect"), and the classified /actions, /automation and /packages rows carry none either. Carrier: none — right. Now that an enforced row with a literal-path dogfood proof exists, the three rows meet the docblock's own fill rule for authz: 'anonymous-deny-analytics' — a one-line follow-up, not a finding.

Fixes #21061: right. The emergency triage grade 5924543711 scoped this card to direction 1 (the door half, domain:cli) and split direction 2 to its own domain:services card, filed as #21079 (card comment 5925061897, Blocked-by: #20995). This PR delivers direction 1 whole: the door, every pin the grade named, and the checklist item. The class members the card title still names beyond the unauthenticated one (a signed-in caller on an embedder that disables the baseline; a picker context with no guest set) are #21079's by the split, so closing #21061 on this PR leaves nothing untracked. Part-of PR must not also close its card: success; The card this PR closes must claim this branch: success.

Governed surfaces: none of the touched paths is a register row (docs/qa/** and content/docs/** are declared cross-lane surfaces, not governed ones); Governed Surface Queue Guard: success. Size on the head: 26 files, +657 / −92 (749 changed lines), far under the 5,000-line threshold; Check PR Size: success.

Check-runs on the head (collapsed latest-per-name; converged conclusions only): 35 check-runs on fa039bb8c5, 35 names after the collapse: 33 success, 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in) — both path-conditional), 0 failure, nothing in progress. All seven queue-required contexts are green: Lint & Repo Gates, TypeScript Type Check, Test Core (and its six shards), Dogfood Regression Gate (and its three shards, the 3/3 shard that was red on 8364f40c4a included), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Also green: Check Changeset, Check PR Size, Part-of PR must not also close its card, The card this PR closes must claim this branch, the same-issue and single-writer-path guards, Spec property liveness, and the four Type Check · jobs. No red check on this head, so none to name against origin/main. The previous head 8364f40c4a converged at 31 success / 2 failure / 2 skipped, the two failures being Dogfood Regression Gate (3/3) and its rollup — PR-caused (③ item 9), not red on origin/main, and closed on this head.

Mergeability: git merge-tree --write-tree origin/main refs/review/pr-21098 with origin/main at 2742e53709 and the review ref at fa039bb8c5: clean, exit 0, no conflicted path, tree 8a721c1da3. Because content/docs/permissions/system-context.mdx is a merge=os-regen path and the driver is registered in this clone, the probe was repeated from a driver-free bare clone in the scratchpad (git clone --bare --shared, then merge-tree --write-tree --name-only on the two shas): clean, exit 0, the same tree. Of the 15 commits main has taken since the merge base b3d7a70864, none touches any of the PR's 26 files (comm over the two name lists is empty; under packages/qa/dogfood/test main added only picklist-shared-across-objects.dogfood.test.ts).

Implemented-by: claude/issue-21061-analytics-anonymous-deny
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 07:14
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 07:14
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 2bddb19 Oct 1, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21061-analytics-anonymous-deny branch October 1, 2026 07:41
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants