Skip to content

fix(service-analytics): the ad-hoc query and sql doors run in a request scope, publishing an inferred cube only after admission - #20407

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20381-adhoc-cube-request-scope
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20381-adhoc-cube-request-scope

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #20381 — scope items 1 and 2 (required). Scope item 3 stays open on the card as a decision; see "What stays open" below.

Clause-②: no

What this changes

AnalyticsService.query() (POST /api/v1/analytics/query) and generateSql() (POST /api/v1/analytics/sql) ran ensureCube over the SHARED cube scope, before callCtx asked the object-level read admission. ensureCube records what it mints in the scope it is given, so:

  • a request refused PERMISSION_DENIED still left the cube it inferred for the refused object in the service-wide registry, and so in every member's getMeta();
  • a suffix measure a caller named on a registered cube (FIELD_sum, FIELD_count_distinct, …) was appended to that cube for every later reader, refused or admitted.

Both doors now run in the request CubeScope that PR #20380 introduced for queryDataset — the same requestScope(), generalised to take no compiled dataset (no second mechanism):

  • ensureCube's inference and augmentation both land in the call's own scope, and the admission, read scope and strategy read them from there.
  • ensureCube now returns the cube it INFERRED (nothing on the augmentation or declared paths). The ad-hoc doors hand it to publishInferredCube AFTER callCtx has admitted the request: that is "CubeRegistry source 3", kept as triage ruled, now written only for an admitted request. First registration wins, so a name the registry gained while the request was being admitted is never overwritten by an inferred cube.
  • An augmented cube is never published. The dataset door (scopedService / queryIn without the flag) publishes nothing, as before.

No refusal is added, and no code or status changes. Boot-time cubes / datasets registration is untouched. No packages/spec change.

What getMeta() lists changes: it no longer lists a cube inferred for a refused request, and it no longer lists a suffix measure some caller named on a registered cube. A cube inferred for an ADMITTED request is still listed (source 3), which is the open question below.

Measurements

All of these were taken on origin/main df3ba164 plus this branch.

  • Premise: holds. queryIn called ensureCube(query, scope) before callCtx (the admission is in callCtx), and generateSql called ensureCube(query, this.sharedScope) before callCtx. Pre-fix route measurement (dist built from df3ba164): the new route pins are 12 of 24 red, and every negative leg fails on the observer-equality line after its 403 envelope assertion passed. The new unit pins are 20 of 24 red.
  • PM assumption 1 (order is the whole defect): the naive order fix is refuted; request-scope-then-publish is what works. For a name with no cube, queryObjects resolves the cube through the scope and returns an EMPTY object set, so an admission asked before ensureCube admits vacuously and never asks about the object. Ablation M0 below moved callCtx ahead of ensureCube on query(). The refused request was then SERVED on both strategies (promise resolved "{ rows: [ { count: 5 } ] }" instead of rejecting), and the admission provider was never called for the object.
  • PM assumption 2 (augmentation never needs to be shared): holds. The full package suite is green with augmentation request-local (131 files, 3077 tests). No in-tree reader outside the call reads an augmented cube. No existing test pinned the shared augmentation, so nothing was rewritten. dotted-measure-refusal.test.ts's warm-registry case, which asserts the registered cube keeps ['count'], stays green.
  • PM assumption 3 (what source 3 is used for). In-tree readers of a registered inferred cube:
    • getMeta(), which lists it;
    • the next request's ensureCube and strategies, which resolve the name to it and take the augmentation branch instead of re-inferring. Re-inference would mint the same cube through the same gates;
    • the plugin's boot log (plugin.ts:1287, the count and names).
      The client SDK exposes analytics.meta(). NOT MEASURED: Studio/objectui consumption (no sibling checkout in this container). getMeta() has no caller context (IAnalyticsService.getMeta(cubeName?) in packages/spec; the runtime route passes none), so it lists every registered cube to every caller.
  • PM assumption 4 (generateSql has the same admission): holds. Measured through the route: /analytics/sql answers the same 403 {"success":false,"error":{"code":"PERMISSION_DENIED","httpStatus":403}} as /analytics/query, from the shared callCtx. No refusal was added to that door.
  • Scope item 3: it leaks. Measured through the route on sqlite-wasm and memory, on this branch's build. After the administrator's ADMITTED ad-hoc query over the walled object, member B lists that object's inferred cube in GET /analytics/meta, with the administrator's measure and dimension member names. B's GET /data of that object answers 403. B's GET /meta/object/... of the same object answers 200 with its field list, so the object name and field names are already readable to B there. What the listing adds is that an admitted caller queried the object since boot, and which member names that caller used. NOT MEASURED: a cross-org boot (the registry is process-wide).

Tests (HEAD 16fc9f3b)

  • packages/services/service-analytics/src/__tests__/adhoc-query-request-scope.test.ts has 24 tests: both strategies × both doors, a second caller as the observer, and whole-snapshot equality. It covers:
    • REFUSED inferred (403 envelope, driver never ran, no registry entry);
    • REFUSED appended (the configured cube is still the authored object);
    • ADMITTED appended (served with the caller's measure, the cube is still the authored object);
    • ADMITTED inferred (the ORDER pin: the admission provider reads the registry when asked, and the inferred cube is not in it yet; afterwards it is registered, source 3);
    • the admission race (a registration made while the request is admitted is kept);
    • CONTROL: an admitted scalar metric works on a second request through the published cube, and that request's suffix measure stays its own.
  • packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts has 24 tests through the real route (bootStack, two sign-ups plus admin), with one boot per driver × door so one door's leg cannot pre-pollute the other's. Every refusal asserts status 403 plus error.code PERMISSION_DENIED plus error.httpStatus 403. The legs:
    • REFUSED inferred and REFUSED appended: B's meta and B's configured-cube answer are unchanged;
    • ADMITTED appended: served with A's measure, and B is unchanged;
    • CONTROL: an admitted scalar metric twice. B's answer is unchanged, and every cube B listed before is listed unchanged;
    • CONTROL: after the admin's admitted query over the walled object, B is still refused on that door.
  • pnpm --filter @objectstack/service-analytics test: 131 files, 3077 passed. typecheck: clean, and tsc --listFiles includes the new test.
  • Dogfood analytics files (the new one, both PR fix(service-analytics): queryDataset compiles into a request scope and never writes the shared registries #20380 route pins, analytics-rls, analytics-label-scope, analytics-timezone): 6 files, 54 passed. pnpm --filter @objectstack/dogfood typecheck: clean, and it includes the new test.
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack over the actual changed paths on 16fc9f3b gives 66 commands, identical to the dispatch-time list. All 66 exit 0. --ran reconciliation: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN. Two commands needed a second run:
    • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3), because eight packages outside the dogfood closure had no dist/. After a turbo build of those packages (41/41 cached), it exited 0.
    • check:type-check-debt was first killed by my own batch timeout. Run on its own, it exited 0.
  • Lint, narrowed: eslint --no-inline-config --format json over the 3 changed TS files gives 3 files, 0 errors, 0 warnings. The population is these 3 files; none is ignored by eslint.config.mjs. The invariance: eslint.config.mjs never enables type-aware linting (its own header states this), so this diff cannot move any untouched file's verdict. The full pnpm lint is CI's.

Ablations

Each ablation used scripts/ablation-replace.mjs in wrap mode, with the fix committed first. The route legs rebuilt @objectstack/service-analytics and ran scripts/ablation-dist-preflight.mjs for both the present and the --absent readings.

leg mutation unit (24) route (24)
M0 callCtx moved ahead of ensureCube in queryIn (the naive order fix) 6 red: refused inferred SERVED; admission never asked —
M1 inferred cube also written to the shared registry inside ensureCube, i.e. before admission 8 red: refused inferred ×4, order pin ×4 4 red: refused inferred, every driver × door
M2 augmented cube also written to the shared registry 10 red: refused/admitted appended ×8, CONTROL ×2 8 red: refused/admitted appended, every driver × door
M3 first-registration-wins guard removed 4 red: race pin ×4 —
  • The dist marker was present in 2 built files for M1 and M2.
  • Every restore was proven the same way: blob 95f2ef9a equals the HEAD blob, git diff HEAD is empty, and the rebuilt dist carries no marker (--absent ✓, tree clean).
  • M2's first attempt was refused by the tool: the replacement contained the anchor, so the anchor count moved 1 → 1. Nothing was measured on that attempt, and its restore was proven. M2 was re-run with a two-line anchor.

Overlap with PR #20348

This PR is textually disjoint from #20348's hunks except for the head of generateSql, and it does not contradict #20348:

What stays open on #20381

Scope item 3. An admitted inferred cube is listed by getMeta() to every member, including members the object-level admission refuses for that object. Closing that needs a door-shape choice:

  • retire source 3;
  • register the cube but leave it out of the listing;
  • a caller-aware getMeta (a packages/spec contract change plus the runtime route);
  • rule it no leak.

Triage reserved that choice, so it goes back as needs_decision with the four-axis analysis. Whichever option is chosen, it is a small follow-up on top of this PR.

Acceptance notes

  • A request that is admitted and then refused by the STRATEGY (for example the ObjectQL decline of a cross-object filter) still publishes its inferred cube, as before. Publication follows admission, per triage item 1. infer-cube-relation-traversal.test.ts ("mints the identical cube for both spellings") reads that cube through getMeta and stays green. This falls inside the item-3 decision space.
  • cube-registry.ts's class doc still describes source 3 without the admission ordering. It is accurate, but less specific than analytics-service.ts now is. It was left untouched to stay inside the card's file surface.
  • The inference branch still logs its auto-inferred a minimal cube line (at warn for grouped queries) before admission. This is a server-side log only, unchanged.

Generated by Claude Code

…st scope, publishing an inferred cube only after admission

query() and generateSql() ran ensureCube over the shared scope before
callCtx asked the object-level admission, so a refused request left its
inferred cube in the shared registry and a caller-named suffix measure was
appended to a configured cube for every caller. Both doors now reuse the
request CubeScope queryDataset runs in; the ad-hoc door publishes an
inferred cube to the shared registry only after admission (first
registration wins), and an augmented cube is never published.

Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ
Co-authored-by: Claude <noreply@anthropic.com>
…view unchanged until admitted

Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 6 documentable anchor(s).

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

  • content/docs/releases/v14.mdx (via generateSql (symbol, a method of class AnalyticsService))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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

Coarse fallback — 9 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 dcd3bceaa068fc3cfb589bd6e04cd0b89b660580 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 43baf4d20d14d009faba9dcbfb5dfa172594efe2 — the merge of head 16fc9f3bfe3082d4cc659be4064163aa97ef9ae7 into base dcd3bceaa068fc3cfb589bd6e04cd0b89b660580, 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 43baf4d20d14d009faba9dcbfb5dfa172594efe2 && git checkout 43baf4d20d14d009faba9dcbfb5dfa172594efe2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dcd3bceaa068fc3cfb589bd6e04cd0b89b660580 16fc9f3bfe3082d4cc659be4064163aa97ef9ae7 && git checkout -B drift-repro dcd3bceaa068fc3cfb589bd6e04cd0b89b660580 && git merge --no-ff 16fc9f3bfe3082d4cc659be4064163aa97ef9ae7

node scripts/docs-audit/affected-docs.mjs --json dcd3bceaa068fc3cfb589bd6e04cd0b89b660580

⚠️ 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 dcd3bceaa068fc3cfb589bd6e04cd0b89b660580 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 16fc9f3bfe3082d4cc659be4064163aa97ef9ae7
Local-runs: none

Inputs: card #20381 (body with its Ruled line, and all 7 comments — triage 5865022533, claims 5865094238 and 5867088629, dev report 5865929789, the seat's accept record 5866067604, decision card 5866076175, ruling 5866558247); PR #20407 body, its 4-file list and the net diff against main (merge-base df3ba164, 3 commits, +627 / −25); the 35 check-runs on the head (32 success, 3 skipped — Build Docs, Console Pin Gate, Packed-tarball smoke — path-filtered or opt-in, 0 failures); file content at the head and at origin/main read through git show only. Security-family lane: the defect and the fix are described at the code-path level; the pins carry the steps.

① Derived judgments

Read from the diff and the head's analytics-service.ts (ensureCube, callCtx, queryObjects, publishInferredCube, requestScope, getMeta, scopedService, the three source-field gates).

  1. Accept set on POST /api/v1/analytics/query — unchanged. Right. queryIn runs ensureCube in requestScope() (an empty request scope that reads the shared registry through), then callCtx asks assertReadAdmitted(queryObjects(query, scope)), which resolves the cube through the SAME scope, so the admission is asked about the inferred cube's object exactly as before. Every refusal keeps its code and status (the route pins assert 403 plus PERMISSION_DENIED plus httpStatus 403). A second caller's suffix measure that was formerly found on a shared, already-augmented cube is now re-minted in that caller's own scope; assertMeasureFields / assertDimensionFields / assertWhereFields judge by object-field existence and use the declared list for suggestions only, so the cold path and the formerly warm path return one verdict. No request previously served is refused; none previously refused is served.
  2. Accept set on POST /api/v1/analytics/sql — unchanged. Right. generateSql takes the same requestScope() / ensureCube / callCtx / publishInferredCube sequence, with the same shared callCtx admission; no refusal added.
  3. The shape is scope-then-publish, not a reorder. Right. The naive reorder (ablation M0) is refuted on the code itself: queryObjects of a name with no cube is the empty set, so an admission asked before ensureCube admits vacuously and never asks about the object. The request scope is what lets the admission read the inferred cube before any shared write.
  4. The shared-registry write set narrows from four sources to three. Right. Before: configuration, registerDataset, ad-hoc inference (refused requests included), ad-hoc augmentation. After: configuration, registerDataset, and inference for an ADMITTED ad-hoc request only (publishInferredCube, called after callCtx has returned). That is triage item 1 (a refused request leaves no trace — the analytics: a measure naming a missing field 500s with SQLITE_ERROR instead of a 400 naming the field #4437 invariant restored) and item 2 (augmentation stays request-local: ensureCube returns only the inference-branch cube; the augmentation branch registers into scope and returns undefined).
  5. First registration wins. Right, and new. publishInferredCube returns without writing when the shared scope already holds the name, so a cube the registry gained during admission is never replaced by an inferred one (the pre-fix CubeRegistry.register overwrote). Narrower than before; pinned (ablation M3, the unit race case).
  6. The dataset door publishes nothing — unchanged from PR fix(service-analytics): queryDataset compiles into a request scope and never writes the shared registries #20380. Right. scopedService calls queryIn(scope, query, context) with no publishInferred; requestScope(compiled) keeps its compiled-dataset behaviour, and the no-argument form only drops the seed entry. One mechanism, as triage required.
  7. getMeta() / GET /api/v1/analytics/meta — the one observable difference. Right, and declared. It no longer lists a cube inferred for a refused request, nor a caller-appended measure on a registered cube; a cube inferred for an ADMITTED request is still listed (source 3, kept as triage item 3 ordered at dispatch time). The changeset names exactly this. Neither removed listing was a published contract: the registry docblock's source 3 never promised a refused request's cube, and analytics: a measure naming a missing field 500s with SQLITE_ERROR instead of a 400 naming the field #4437 already calls the appended entries a convenience, not a vocabulary.
  8. Public API surface — unchanged. Right. No packages/spec change; IAnalyticsService and the query / generateSql signatures are as on main; queryIn (new options parameter), requestScope (parameter made optional), ensureCube (now returns a value) and publishInferredCube (new) are all private. No Zod key, no closed-set member, no api-surface row, no registry or catalog registration anywhere in the diff — no widening tell against the Clause-②: no declaration.
  9. Publication precedes the strategy. Right as scoped; noted. publishInferredCube runs after callCtx and before resolveStrategy, so an admitted request the strategy then declines still publishes its inferred cube. Triage item 1 asks for admission, not strategy success; ruling 5866558247 (A) retires the publication altogether next round, which dissolves the case.
  10. The inference log line still emits before admission. Right, no action. Server-side debug (scalar) / warn (grouped) only; not a client-visible surface; unchanged by this diff and unaffected by ruling A, since request-local inference stays.
  11. Tests match the card's pins on this head. Right. Unit: 24 cases, both strategies × both doors, a second caller as observer with whole-snapshot equality, the refusal envelope with the driver never running, the ORDER pin (the admission provider samples the registry while it is asked), the race pin, and the control through the published cube. Route: 24 cases through bootStack, two drivers × two doors with one boot per pair (so one door's leg cannot pre-pollute the other's), two sign-ups plus admin, every refusal asserting the full envelope. The dogfood gate's isolated vitest project takes the new file by glob; the 3 dogfood shards, the rollup and the completeness attestation are success on this head. The two CONTROL legs use arrayContaining on B's cube list deliberately: triage's own item 3 kept source 3 for an ADMITTED inference on this head, so the triage pin's "admitted leaves B's meta unchanged" holds here in its appended-measure form (exact equality) and is relaxed to a superset for the inferred form; ruling A orders it tightened to exact equality in the follow-up round.
  12. Nothing judged wrong. The diff stays inside the claim's file surface (analytics-service.ts, one unit file, one route pin, one changeset); cube-registry.ts, boot-time registration and packages/spec are untouched.

② Semver level

  • .changeset/20381-adhoc-cube-request-scope.md: @objectstack/service-analytics: patch, body carrying Clause-②: no on its own line and naming the one observable difference (the getMeta listing). Not skip-changeset. Right: a bug fix in a released package; no export, key, arm or field added, none removed or renamed; no migration owed, no ADR-0087 disposition owed. The package sits in the fixed lockstep group; Check Changeset and Lint & Repo Gates (the level-axis reader and the widening-tells gate) are success on the head.
  • Clause-②: no — declared identically on the PR body, the changeset and both claims (5865094238, 5867088629). Right, with no direction arm: nothing widens the accept set or the public surface (judgments ① 1, 2, 8), and nothing narrows a PUBLISHED accept set — the diff pulls the code back to the declared contract, the analytics: a measure naming a missing field 500s with SQLITE_ERROR instead of a 400 naming the field #4437 no-trace invariant (judgment ① 7 is why the arm is not (narrowing)).

③ Boundary flags

Every dev flag — PR body "What stays open", "Acceptance notes", "Overlap with PR #20348", the NOT MEASURED lines; dev report open_questions and out_of_scope_findings — answered or escalated:

  1. Item 3 / open_questions[0] — an ADMITTED request's inferred cube is listed to every member. Measured to leak; escalated by the seat as decision card 5866076175; ruled 5866558247 letter A — retire source 3 in a follow-up round on this file, with PR fix(service-analytics): the ad-hoc query and sql doors run in a request scope, publishing an inferred cube only after admission #20407 landing regardless (the ruling's own execution parameters). Answered; not a blocker for this head.
  2. Acceptance note (a) / out_of_scope_findings[0] — a strategy-declined admitted request still publishes. Inside the item-3 decision space; dissolves under A. Answered by the ruling.
  3. Acceptance note (b) / out_of_scope_findings[1] — the cube-registry.ts source-3 sentence lacks the admission ordering. Accurate on this head and left outside the file surface; ruling A's execution parameters order the docblock rewritten in the follow-up round. Escalated to that round.
  4. Acceptance note (c) — the inference log line before admission. Judged in ① 10: server log only, no action.
  5. Overlap with PR feat(analytics): enforce analytics_cube.public and default it to visible #20348 / out_of_scope_findings[2] — its generateSql gate asks the shared scope. Textually disjoint except at the head of generateSql; the later lander hoists const scope = this.requestScope() above that gate; the answer is identical today. Carried as declared overlap in claim 5867088629. Escalated to the later lander.
  6. NOT MEASURED: cross-org boot. The registry is process-wide; this diff only reduces what reaches it; the remaining admitted-inferred publication is what ruling A retires, and the ruling files the case as a D5-audit note. Escalated.
  7. NOT MEASURED: Studio / objectui consumption of meta. The ruling records the director grep: objectui reads /analytics/meta nowhere. Answered.
  8. NOT MEASURED (decision card): an FLS-hidden field as a dimension. Ruling: a D5-audit note; cannot leak through meta for inferred cubes once A lands. Escalated.
  9. Dev deviation from the dispatch — Part of #20381, no closing keyword. Right for a card that went to the decision box and still carries item 3; Part-of PR must not also close its card is success.
  10. Dev-side gate notes — check:dual-build-cjs-loads (exit 3 until the closure was built, then 0), check:type-check-debt (batch timeout, then 0 standalone), lint narrowed to the 3 changed TS files. Answered by the head's check-runs: Type Check · debt ledger, Type Check · consumer gates, Type Check · source gates, Type Check · workspace, TypeScript Type Check, Lint & Repo Gates, Build Core, Test Core (6/6) — all success.
  11. Docs Drift Check (PR comment 5865903571) — content/docs/releases/v14.mdx names generateSql. Release-owned, read-only, correctly not edited; generateSql's signature and its answer for an admitted request are unchanged, so the page is not falsified. No action.
  12. Route-test CONTROL legs on arrayContaining (decision-card premise 4). Deliberate on this head (judgment ① 11); tightened in the ruling-A round. Answered.

Nothing remains unanswered and un-escalated. The diff touches no governed surface (Governed Surface Queue Guard is success); the PR is a draft with a clean mergeable state; the landing to-dos in the seat's accept record 5866067604 (readiness, auto-merge, then the assignee release) are the owning seat's acts, not this record's.

Implemented-by: claude/issue-20381-adhoc-cube-request-scope
Reviewed-by: session_017B6YKCGu8CTY2KBWgwaHAs

VERDICT: PASS

Rendered by an isolated contract-review subagent and adopted by the domain:services seat (#6021, session_017B6YKCGu8CTY2KBWgwaHAs) at 2026-09-28T09:34Z after a transcript check: 89 harness model stamps, all at CONTRACT_REVIEW_TIER, zero fallbacks; 49 Bash and 2 Read calls, zero write calls. Fed the card, the ruling and the PR only, never the dispatch text or the seat's conclusions.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 09:35
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 50e273f Sep 28, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20381-adhoc-cube-request-scope branch September 28, 2026 09:54
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…no request writes the shared cube registry (objectstack-ai#20381) (objectstack-ai#20433)

Fixes objectstack-ai#20381
Clause-②: no

Item 3 of objectstack-ai#20381, under director ruling `5866558247` (letter A,
maintainer 「同意」): registry source 3 is retired. Items 1–2 landed in PR
objectstack-ai#20407 (`50e273fd`), so this round completes the card.

## What changes

- The ad-hoc `query` and `sql` doors (`POST /api/v1/analytics/query`,
`POST /api/v1/analytics/sql`) no longer publish the cube `ensureCube`
infers for an ADMITTED request. That cube stays in the call's request
scope, the one PR objectstack-ai#20407 gave these doors, and it is dropped with the
call, like a measure appended to a configured cube. The next request for
the same name infers the cube again, through the same existence and
source-field gates, and gets the same answer.
- The shared `CubeRegistry`, and therefore `getMeta()` and `GET
/api/v1/analytics/meta`, is now written by configuration only: manifest
cubes (`AnalyticsServiceConfig.cubes`) and `registerDataset` datasets.
`/analytics/meta` lists the authored vocabulary, whatever traffic the
server has seen since boot.
- `publishInferredCube` had no caller left and is removed, and
`ensureCube` returns `void` again. The `CubeRegistry` class docblock now
lists the two configuration sources and states that no request writes
the registry. The `CubeScope`, `queryIn`, `requestScope`, `ensureCube`
and objectstack-ai#5918 comments in `analytics-service.ts` no longer describe the
publication.
- No refusal, code or status changes. There is no `packages/spec`
change, no visibility marker and no caller-aware `getMeta`; options B, C
and D are not taken.

Landing point, as dispatched:
`packages/services/service-analytics/src/analytics-service.ts` (the
producer of the write) and `cube-registry.ts` (docblock only).

## Tests: re-observed, not deleted

On the fix commit, 36 cases in six service-analytics test files went
red; each of those files read an inferred cube back through `getMeta` or
the shared registry. PR objectstack-ai#20348, which landed while this round ran, added
a seventh such case. Each case is re-observed through a window that
still exists after A: the cube the request's own strategies are handed.
A probe strategy placed ahead of the built-in ones records
`ctx.getCube(query.cube)` and always declines, so the chain runs as it
would without the probe. Where an assertion's subject was the retired
registration itself, the assertion now pins its absence.

| File | Was | Now |
|---|---|---|
| `infer-cube-where-spelling-parity.test.ts` | dimension keys via
`getMeta('deal')` | the same keys, read from the request's cube |
| `infer-cube-relation-traversal.test.ts` | `run()` members via
`getMeta` | the request's cube |
| `dotted-measure-refusal.test.ts` | `run()` measures via `getMeta`;
block 2 case 1 asserted that the first query warmed the registry | the
request's cube; case 1 now pins that the first query warms nothing and
that the second, cold again, is still refused (the augmentation site
stays covered by the block's authored-cube case) |
| `analytics-service.test.ts` 'auto-infer' | `cubeRegistry.has('case')`
is true | the request was handed a cube named `case` and backed by
`case`; `has('case')` is false |
| `cube-inference-gate.test.ts` KPI case |
`cubeRegistry.get('crm_account')` is truthy | it is undefined; a second
request is served the same way and asks the existence gate again |
| `adhoc-query-request-scope.test.ts` (PR objectstack-ai#20407) | the admitted
inference "publishes after admission (source 3)"; the CONTROL case runs
"through the published cube" | the admitted inference is served from its
own cube, and both the registry and the observer's view are exactly
unchanged (the order pin is kept); the CONTROL case is now "a second
same-name request infers again and gets the same answer" |
| `cube-public-visibility.test.ts` (PR objectstack-ai#20348) | the ad-hoc KPI path's
inferred cube is registered `public: true` and listed | it is answered
on every request, and never registered or listed |

The route pin is
`packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts`:
`bootStack` with two sign-ups plus the administrator, on sqlite-wasm and
memory, through both doors.
- Both CONTROL legs are tightened from `arrayContaining` to exact
equality on member B's cube list. B's `meta` is also kept as the raw
response bytes.
- After the administrator's admitted ad-hoc query over the walled
object, B's `meta` is byte-identical and B's query of the configured
cube is unchanged. B's own query of that object is still refused with
the ADR-0112 envelope.
- An admitted scalar metric is re-inferred on a second request and
answers identically. Between the two requests, not even the asker's own
`meta` lists the name.
- A configured cube still serves: the baseline leg, and every
observation.

## Evidence (head `8214a5b6` unless stated)

- `pnpm --filter @objectstack/service-analytics typecheck` is clean.
`vitest run`: 132 files, 3093 passed. `tsc --listFiles` includes every
changed test file.
- `pnpm --filter @objectstack/dogfood typecheck` is clean, and
`--listFiles` includes the route pin. The six analytics dogfood files:
54 passed, on a `dist` rebuilt after merging `main`.
- **Ablation M1** puts the publication back at both ad-hoc sites, after
`callCtx`, as in `50e273fd`. It was applied with
`scripts/ablation-replace.mjs` (anchor 2 → 0) and predicted before
running.
- Unit, 7 files: 10 red / 132 green. The red cases are the
admitted-inference and CONTROL cases (4 + 2), auto-infer, the KPI gate
case, the objectstack-ai#20348 KPI case and the dotted warm case.
- Route, after rebuilding `service-analytics`, with
`ablation-dist-preflight` finding the marker in 2 dist files: 8 red / 16
green, both CONTROL legs on all four boots. For example: `expected [
'open_summary', …(3) ] to deeply equal [ 'open_summary', …(2) ]`, with
`+ "admission_walled"`.
- Restore: blob equals `HEAD`, `git diff HEAD` is empty, rebuilt, and
`--absent` preflight is green with a clean tree.
- On the pre-merge head `fccfc3e5` the same ablation gave 9/117 and
8/16.
- **Ablation M2**, on `fccfc3e5`, proves the probe window can fail. It
makes an array `where` seed no dimension, the pre-objectstack-ai#5353 shape. Across
parity and traversal: 16 red / 24 green. In parity, the 11 conjunction
table cases, ALONGSIDE and the two dotted array-versus-object cases went
red; both `$or` cases stayed green, as the file predicts. In traversal,
the two array-spelling mint cases went red. The restore was proven the
same way.
- **Gates**: `dispatch-gates --commands` derives 66 commands over the 12
changed paths. `--ran` reconciles 66 derived, 66 run, 0 NOT MEASURED and
0 UNRUN. 65 exit 0; `check:empty-changeset` exits 1 by design (see
Changesets).
- **Lint, narrowed**: eslint `--no-inline-config --format json` over the
10 changed `.ts` files reports 10 files, 0 errors and 0 warnings. The
population is those 10 files, none ignored. Invariance:
`eslint.config.mjs` never enables type-aware linting, so an untouched
file's verdict cannot move. The full `pnpm lint` is left to CI.

## Changesets

- New: `.changeset/20381-retire-inferred-cube-source.md`,
`@objectstack/service-analytics` `patch`, `Clause-②: no`.
- **A deliberate correction of a pending release note, for
confirmation:** `.changeset/20381-adhoc-cube-request-scope.md` was added
by PR objectstack-ai#20407 and is not yet released. It said that an admitted request's
inferred cube "still registers the cube it inferred, as before" and that
it "is still listed". This PR makes both sentences false, so they are
removed and replaced by a pointer to the new entry.
`check:empty-changeset` refuses any PR that modifies a changeset it did
not add. For this DELIBERATE CORRECTION class it stays red by design
(ruling D on objectstack-ai#17712), and it needs a person's confirmation here.
Restoring the file from `50e273fd` would clear the gate, but the release
would then ship both statements in one CHANGELOG.

## Overlap with PR objectstack-ai#20348

PR objectstack-ai#20348 landed first (`f2c7eef5`), and `main` is merged here
(`dfd185d7`) with no textual conflict.
- Its `public: true` on the inferred cube is moot under ruling A,
because no visibility verdict ever reads that cube. The literal is
**kept**: the pending note
`.changeset/20282-analytics-cube-public-enforced.md` says the inferred
cube "now writes `true`", and dropping the key would falsify a second
foreign changeset. Only its comment, which said the cube is registered,
is corrected.
- Its `generateSql` gate still asks `this.sharedScope` rather than the
call's scope. The answer is identical, because a fresh request scope
with no dataset reads through to the shared registry, so this is noted,
not changed.
- Its other changes are untouched.

## Acceptance notes

- Log frequency: for a GROUPED ad-hoc query over an object with no
configured cube, `ensureCube`'s `warn` ("No cube registered …;
auto-inferred a minimal cube …") used to fire once per name per process,
because the second request found the published cube. It now fires on
every such request; scalar metrics stay at `debug`. This was not
measured against real dashboard traffic, and it is noted, not changed:
the ruling adds no state.
- `content/docs/api/data-api.mdx`, in its `GET /analytics/meta` section,
says that a cube a query references "is lazily auto-inferred from that
query's shape". It does not claim the cube gets listed, but it could now
say that it does not. That file is outside this card's file surface.
- Per the ruling, the two unmeasured cases (a cross-org boot, and an
FLS-hidden field used as a dimension) cannot leak through `meta` for
inferred cubes once this lands. They stay as notes for the ADR-0106 D5
audit.
- The refusal tests that assert `cubeRegistry.get(...)` is undefined
after a rejected query (the three source-field gate files,
`cube-inference-gate`, `dotted-measure-refusal`) now hold by
construction for every request, not only for refused ones. They are left
as they are.

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

---------

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

Development

Successfully merging this pull request may close these issues.

3 participants