Skip to content

fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) - #20931

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20917-analytics-field-permission-gate
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20917-analytics-field-permission-gate

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20917
Clause-②: yes (narrowing)

One admission gate now judges every member an analytics query names against the caller's field-level read permissions, before either strategy runs. A member the caller may not read answers the engine's own refusal: 403 PERMISSION_DENIED, in the engine's words. The gate sits at the analytics door (AnalyticsService.callCtx, right after the object-level admission and before strategy selection), so NativeSQLStrategy, ObjectQLStrategy, the SQL echo and any strategy added later inherit it by construction. There is no second copy of the permission rule: which fields are readable is the security service's answer, and the analytics layer contributes only the resolution from member to field.

Why line 2 is yes (narrowing), not the claim's expected no (narrowing)

Both directions measured:

  • Narrowing. On the native-SQL strategy every position in the table below was answered and is now refused. On the ObjectQL strategy an order key naming a hidden field was ignored and is now refused, a joined filtered member moves from 400 INVALID_FIELD to the engine's refusal, and the SQL echo printed the statement and now refuses.
  • Widening. AnalyticsServiceConfig gains one optional member, getReadableFields, the hook AnalyticsServicePlugin fills. It is a new member of a published config type, so the public surface grows by one hook. No query accept set widens. (There is no matching plugin option: nothing in the tree passes the object-level sibling admitObjectRead either, so the plugin always bridges to the security service.)

@objectstack/service-analytics ships minor with the BREAKING banner and an ADR-0087 not-required (no-migration-prescription) disposition. check-adr-0087-registration and check-changeset-no-major pass.

Per position

Measured on SQLite with the real SecurityPlugin, ObjectQL and SqlDriver, as a member whose permission set hides fields on the queried object and on a related one. "Refusal" means 403 PERMISSION_DENIED whose code, status and message equal what the engine answers for the same field as the same caller: engine.aggregate for a member the query groups or aggregates, engine.find for one it filters or sorts by. Each face is the cube read (AnalyticsService.query, what POST /api/v1/analytics/query relays) and, per row, the dataset door (POST /api/v1/analytics/dataset/query) where the position exists there.

position native-SQL strategy: before → after ObjectQL strategy: before → after
grouped member (dimension) answered, the hidden values as group keys → refusal refusal → refusal
aggregated member (measure) answered, an aggregate over the hidden field → refusal refusal → refusal
bucketed time dimension refusal (the strategy declines, the engine refuses) → refusal refusal → refusal
time-dimension window answered, rows selected by the hidden field → refusal refusal → refusal
filtered member, including under $or / $not answered, rows selected by the hidden field → refusal refusal → refusal
order key answered, groups ordered by the hidden field → refusal answered, the key ignored → refusal
joined member, grouped: a dataset include, an authored join, an inferred cube's relationship path answered, the related hidden values → refusal on the related object refusal → refusal
joined member, filtered answered, rows selected by the related hidden field → refusal on the related object 400 INVALID_FIELD (cross-object filter) → refusal
a dataset's own filter; a requested measure's own filter answered → refusal refusal → refusal
authored cube alias over a hidden field: grouped, aggregated, filtered, joined answered → refusal naming the field the alias resolves to refusal (joined filtered: 400) → refusal
SQL echo (AnalyticsService.generateSql, what POST /api/v1/analytics/sql relays), every row above printed the statement → refusal printed the statement (joined filtered: 400) → refusal
readable members (the control) answered → answered, unchanged answered → answered, unchanged

The dataset door answers every refusal as 403 with { code, message } and no rows beside it.

The reader, and how the gate reaches it

  • engine.find and engine.aggregate refuse a hidden field in plugin-security's middleware: the aggregate-input guard for a grouped or aggregated field, the predicate guard for a filtered or sorted one. Both build their mask from the caller's permission sets through permissionEvaluator.getFieldPermissions, the requiredPermissions fold and the on-behalf-of delegator intersection.
  • The published cross-package reader of that same derivation is ISecurityService.getReadableFields(object, context) (resolveProjectionFieldMask). It is called, not changed: no export or signature change in objectql, spec or plugin-security.
  • AnalyticsServicePlugin bridges AnalyticsServiceConfig.getReadableFields to the registered security service at call time, with the three resolutions its object-level and row-scope bridges keep apart. No security service: no field-level gate, as on /data. A service that throws on resolution or carries no getReadableFields: the query is refused, fail-closed, logged at error. Otherwise: ask it, once per object the query names a field of, with the caller's context.
  • The analytics layer adds the member resolution (namedQueryFields in analytics-service.ts, the judgement in the new field-read-admission.ts). Each member is resolved as the strategies resolve it (declaredMemberEntry). A joined member is resolved through the cube's join at each hop, and its relationship fields are judged on the object before them, as the engine judges a path's first segment. Filter members are read through the strategies' own lowering (normalizeAnalyticsFilterTree + collectFilterLeaves).
  • The words are the engine's two refusals, verbatim: the aggregate refusal for a grouped or aggregated member, the predicate refusal for a filtered or sorted one. On one object the aggregate refusal speaks first, and the base object before a joined one, as on the engine path. The draft-preview branch of the dataset door asks the same gate before it evaluates drafted rows.

What is judged, and what is not

  • Triage's floor: dimensions, measures, filter members, joined members and time-dimension members. Also order keys: the native statement ordered by the named column.
  • Authored versus inferred cubes: a member is judged by the field its sql resolves to, never by its name in the cube. Both are measured above and pinned.
  • A dataset's own filter and a requested measure's own filter: judged. The nearest engine analogue, measured: the ObjectQL strategy hands both to the engine, which refuses a hidden field in either. On the inline dataset door the caller writes both.
  • A host read scope: not judged. The engine's field guard runs on the caller's own predicate before row-level policy is composed, and exempts policy predicates by design: they may name fields the caller cannot read. A read scope naming a hidden field is served, as on /data; pinned.
  • Stand-downs, each pinned: a member of an authored cube whose sql is an expression names no field the gate can attribute (flagged for a decision in the dev report); an object the reader answers "no answer" for has none of its fields judged, since an object the security service cannot resolve is one the engine serves nothing from; a name the object's declared field list does not carry is not judged, and with no field list available every name is.

Pins, committed red ahead of the fix, pushed together with it

  • 77f73705a pins, then 40c4979d8 the gate, then 992a592db the changeset and the plugin tidy.
  • packages/rest/src/analytics-field-permission-gate.test.ts: both compositions over the real SecurityPlugin, ObjectQL and SqlDriver; the cube read and the SQL echo over the inferred cube and an authored cube, and the dataset door through this package's route; every refusal compared with the engine's live answer for the same field, computed in the same test. Readable members and a system caller are the controls. Against the base tree: 6 of 12 red (the three position tests per strategy), 6 green (the references and the controls). Now 12 of 12.
  • packages/services/service-analytics/src/__tests__/field-read-admission-gate.test.ts: every position through both strategy paths from one table with nothing executed; the words and their order; the stand-downs; a throwing reader refused fail-closed; no reader wired; the draft-preview branch; and the plugin's bridge. With the three source files restored to the pins commit (by absolute path, the restore proven byte-identical to HEAD and git diff HEAD empty): 39 red, 9 green, the 9 being the stand-down and no-reader controls. Now 48 of 48.
  • The client census (envelope-caller-census.test.ts) counts no new call site: the pins call the producer through a receiver named service. Its suite passes unchanged.

Ablation, predicted before running, at 40c4979d8

The door's gate call was replaced by a no-op through scripts/ablation-replace.mjs (anchor hit once, blob moved), @objectstack/service-analytics rebuilt, and ablation-dist-preflight found the marker in 2 built files. Predicted: route pin 6 red and 6 green; unit pin 38 red and 10 green (the draft-preview branch keeps its own call, so it stays green). Observed: exactly that. The restore leg proved the blob equal to HEAD, git diff HEAD empty and the whole tree clean, rebuilt, and found the marker absent from all 6 built files.

Verification, at 992a592db

  • @objectstack/service-analytics: test 146 files, 3374 passed; typecheck exit 0, with 144 of 144 __tests__ files in the tsc program (--listFiles).
  • @objectstack/rest: the 12 analytics-* route files plus error-response-structured-arm-door-parity, 13 files, 215 passed and 3 skipped; typecheck passes, including the test layer (check:test-typecheck OK).
  • Consumer sweep, narrowed to the test files that load this package: @objectstack/runtime analytics-* (3) plus cross-field-refusal-operand-withhold, 28 passed and 4 skipped; @objectstack/client analytics-automation-json-erasure plus the census, 27 passed; @objectstack/dogfood analytics-* (6 files), 54 passed.
  • Gates: dispatch-gates --commands derived 62. All 62 were run, plus the 4 roster families (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity), all exit 0. dispatch-gates --ran, fed each command with its exit code: 62 derived, 62 run, 0 NOT-MEASURED, a derived zero. check:dual-build-cjs-loads first exited 3 (prerequisite not met) and check:type-check-debt was cut by my own runner's time limit; both were re-run green after turbo run build over ./packages/*.
  • Lint, narrowed and proved: the population is the 5 changed .ts files, none ignored by eslint's own config (isPathIgnored false for all 5). eslint --no-inline-config over them gives 5 files, 0 errors, 0 warnings. parserOptions.project and projectService are unset for all 5, so no type-aware rule runs and no untouched file's verdict can move.

Docs

No hand-written content/docs/** page states how the analytics routes treat field permissions. permissions/authorization.mdx states the engine's field guard in general terms and stays true; permissions/index.mdx names the analytics row-scope bridge only.

Acceptance notes


Generated by Claude Code

…cs door (#20917)

Every member an analytics query names, judged against the caller's
field-level read permissions before either strategy runs, answering the
engine's own refusal: the cube read and the SQL echo over the inferred and
an authored cube, and the dataset door, on both strategies, with the real
SecurityPlugin, ObjectQL and SqlDriver; plus the service-level gate and the
plugin's bridge to the security service. A readable member is the control.

Committed ahead of the change that satisfies them.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…oor, before either strategy (#20917)

The analytics admission step now resolves every member a query names --
dimensions, measures, time dimensions, where members, order keys, joined
members, and a compiled dataset's own and its requested measures' filters --
to the field it reads, and judges each against the caller's readable fields
before a strategy is selected. A member the caller may not read answers
PERMISSION_DENIED / 403 in the engine's words. The native-SQL strategy held
no field permissions and served such members; it now inherits the verdict by
construction, as does the SQL echo and any strategy added later.

The permission rule stays the security service's: the plugin bridges the new
getReadableFields hook to its getReadableFields reader, and the analytics
layer contributes only the member-to-field resolution. A host read scope is
policy and is not judged, as the engine's own field guard does not judge it.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…ce's; changeset (#20917)

The plugin no longer offers its own option for the field-level reader: it
always bridges AnalyticsServiceConfig.getReadableFields to the security
service, and a host composing its own reader constructs AnalyticsService with
it. The changeset records the narrowing, the new optional service hook, and
the ADR-0087 disposition.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json aaad682dbcb36bd00635a40530aca826062b9903.

⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 36 pages)
  • 4 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 — 10 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 aaad682dbcb36bd00635a40530aca826062b9903 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 aaad682dbcb36bd00635a40530aca826062b9903 → 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: 992a592dbbeaf200e16fd48051546f1d981b6295
Local-runs: none

Inputs: card #20917 (body, triage 5917636106, claim 5917793643, dev report 5919006124, ACCEPT 5919159814), PR #20931 (body, 6-file list, net diff against main from merge base 4d0b9cd54), and the check-runs on the head, read latest-run-per-name at 2026-09-30T20:37Z — 34 names, all completed, none failing; the seven required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard) all success, the last of them completing 2026-09-30T20:34Z. main has moved since the merge base; nothing that landed touches a file this PR changes. Head repo = base repo, draft, no governed path, 1,194 changed lines. Disclosure discipline held: this record names positions and classes, no request body, header, field spelling or returned value.

① Derived judgments

  1. Accept-set narrowing at the door — RIGHT, and where triage put it. The gate runs in AnalyticsService.callCtx immediately after the object-level admission and before strategy selection. callCtx is the one seam query(), generateSql() and the DatasetExecutor path share, so the cube read, the SQL echo and every dataset door inherit it; the draft-preview branch (the one dataset path that never reaches query()) asks the same helper through its preview proxy. It is not a per-strategy decline. Members collected: dimensions; measures, read from the call's scope after ensureCube, so suffix-minted measures are judged by the field they aggregate; time dimensions (bucketed → aggregate role, window-only → predicate role, with declared granularity defaults already applied); where leaves; order keys (the object form — the only form AnalyticsQuerySchema admits, orderBy being an alias and filters a tombstone); a dataset's own filter and each requested measure's filter; joined members through the cube's join at each hop, the hop's relationship field judged on the object before it. Each member resolves to its (object, field) through declaredMemberEntry, the same lookupMember rule both strategies compile with, so what is judged is what reaches the statement.

  2. Refusal shape — RIGHT as far as the pins reach. PERMISSION_DENIED / 403 with the engine's aggregate-input text for a grouped or aggregated member and its predicate-guard text for a filtered or sorted one; base object before joined, aggregate before predicate on one object. The route pin compares code, status and message with the live engine's answer for the same field as the same caller on every single-role case, both compositions, three faces, inferred and authored cubes. The aggregate-first order on a mixed-role query is pinned only against the gate's own words — a wording-order question, not a permission one; both orders refuse.

  3. Reader and fail direction — RIGHT. The plugin bridges AnalyticsServiceConfig.getReadableFields to the security service at call time, copying the object-level bridge's three-way split: absent → no field gate (the deployment /data has no field-level security on either); unusable (resolution threw, or no getReadableFields) → refused fail-closed and logged at error; usable → asked once per object with the caller's context. Read against plugin-security's resolveProjectionFieldMask: undefined is answered only for an empty name or an unresolvable schema (gate skips — no field permission can exist for an object the registry cannot resolve); a system context and a caller with no permission sets receive the full field set (parity with the middleware's isSystem skip and its non-empty-sets guard, so neither is newly refused); an unresolved security posture or a dangling delegator answers the empty list (every named field refused — the middleware's own fail-closed stance). The contract file describes this reader as "advisory / fails soft" on the premise that the read path has already deleted keys; on the native path nothing deletes, and the gate's reading of throw (closed) and undefined (schema-less only) is what makes the reader safe to lean on. The one known width difference — a field served partially masked is listed readable — is the reader's, filed as security(analytics): a field the caller may only see masked is answered unmasked as a grouped or filtered member on the native-SQL strategy; the published field reader has no masked-for-this-caller answer #20935, not this gate's.

  4. knownFields stand-down — RIGHT. A name the object's declared field list does not carry is not judged; with no list, every name is. The plugin's getObjectFieldNames reads the registry's getObject(...).fields, and resolveProjectionFieldMask builds its universe from the same live SchemaRegistry, so any field a permission set can hide sits in both lists; the stand-down spares only relationship-path segments and names outside the object, which the reader's list could never contain.

  5. where collection through the strategies' own lowering — RIGHT. normalizeAnalyticsFilterTree(…, NO_DATETIME_COLUMNS) + collectFilterLeaves, standing down when the lowering refuses. In the shared lowering (spec/data/filter-lowering.ts) isDatetimeColumn only decides whether a bound is rewritten (whole-day rule, $between split), never whether the tree is refused, so a tree that throws under the gate's reader throws under the strategy's typed reader too, and the members named are identical. The dataset scope the gate reads is the registry's raw copy rather than the per-request token-resolved one; member names carry no tokens, so the verdict is the same — noted, not a defect.

  6. Expression stand-down — pinned, escalated (③). An authored member whose sql is neither a bare identifier nor an identifier path names no field. It preserves today's behaviour on the native strategy for authored metadata only; the ObjectQL strategy refuses expression measures already. Not a position the card names.

  7. Public-surface widening — RIGHT, and exactly one. The exported AnalyticsServiceConfig type gains one optional member, getReadableFields. ReadableFieldsProvider, NamedField, FieldReadRole and the two helpers in field-read-admission.ts are not re-exported from the package index. No plugin option was added: the reader is always the security service's, and a host composing its own reader constructs AnalyticsService with it — consistent with "no second copy of the rule".

  8. Boundaries and neighbours — RIGHT. No objectql, spec or plugin-security path; no governed path; the client census needs no row (the pins call through a receiver named service; the census suite is inside the green Test Core). No hand-written docs page states how analytics treats field permissions — the capability page's general sentence that permissions apply inside every chart becomes truer, not false. ensureCube's 400 INVALID_FIELD gates still answer ahead of the 403, the same unknown-versus-hidden distinction the data API makes — not a new disclosure. ADR-0021 D-C (row scope per joined object) and ADR-0066 ⑧ (block-list FLS posture) stand untouched; the gate inherits the posture through the reader. No adr-anchor names the touched files.

  9. Evidence, as the PR states it and CI confirms it; nothing re-run here. Pins committed red first (77f73705a), gate on top (40c4979d8), changeset and plugin tidy (992a592db), pushed together. Route pin: 6/12 red → 12/12; unit pin: 39/48 red → 48/48; ablation of the door's gate call predicted then observed exactly (route 6/6, unit 38/10 with the preview branch keeping its own call). Controls: readable members, joined readable members, the SQL echo, and a system caller answered for the same hidden member.

② Semver level

  • Clause-②: yes (narrowing) — right, on the PR body's line 2 and in the changeset. Widening: one optional member on an exported config type, so yes and at least minor. Narrowing: the analytics doors now refuse what the native-SQL strategy answered, so BREAKING. The claim's expected no (narrowing) was corrected by measurement, and the PR body writes out both directions.
  • Changeset @objectstack/service-analytics: minor, ! in the title, the BREAKING banner, the operator remedy, and the adr-0087 marker not-required (no-migration-prescription) — right: the launch-window convention (check-changeset-no-major) ships a breaking change as minor with the banner as the carrier, and no authorable key, export or stored shape is removed or renamed, so there is no FROM → TO to prescribe. Check Changeset and Lint & Repo Gates are green on the head. @objectstack/rest gains a test file only and publishes nothing, so its absence from the changeset is right. Not patch (Clause-② is yes), not skip-changeset.

③ Boundary flags

Implemented-by: claude/issue-20917-analytics-field-permission-gate
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 20:39
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 1571aed Sep 30, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20917-analytics-field-permission-gate branch September 30, 2026 21:01
os-justin pushed a commit that referenced this pull request Oct 1, 2026
main's #20931 (the field-read admission gate), #20955 (the queryable-field
gate), #20954 (plugin-security's comparand guard) and #20962 (relationship
path objects in the admitted and scoped set) touched
packages/services/service-analytics. The merge is clean at the text level;
both sides' additions to analytics-service.ts and native-sql-strategy.ts are
kept whole.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… cross-field comparand exactly as it is as a filter key (objectstack-ai#20932) (objectstack-ai#20954)

Fixes objectstack-ai#20932
Clause-②: no (narrowing)

## What this changes

The security layer's field predicate guard refuses a query that filters,
sorts, groups or aggregates by a field the caller's field-level
permissions hide (`403 PERMISSION_DENIED`), because which rows answer
would disclose the value the field mask withholds. It collected the
fields a condition names from the condition's **keys** only, so a hidden
field named only as a **cross-field comparand** was never judged by that
rule.

`collectConditionFields` now also collects the field every cross-field
comparand inside a field constraint names, into the same set, and the
existing field rule judges it. One collection, one rule, no second
guard. A hidden field as a comparand now answers the same refusal (code,
status and body) as the same hidden field written as a key.

Dispatched by the `domain:services` seat (seat post objectstack-ai#6021), session
`session_01XY5uCwTjZj7884yYtyur4H`; claim comment 5919655256, triage
direction 5919556782.

## Per position (disclosure-safe: classes, no request shapes)

| Comparand position the filter grammar admits | Door | Before (class) |
After |
|---|---|---|---|
| The whole comparand of a scalar comparison (each of `$eq` `$ne` `$gt`
`$gte` `$lt` `$lte`) | data read | served: the comparison was answered |
`403 PERMISSION_DENIED`, body equal to the key form's |
| Under a logical group (`$and`, `$or`, `$not`, and nested groups) |
data read | served | same refusal as the key form |
| The whole-day offset wrapper: its base reference | data read | served
| same refusal as the key form |
| The whole-day offset wrapper: its offset column | data read | served |
same refusal as the key form |
| The query condition of a grouped query (plain, and under `$or`) |
aggregate path | served | same refusal as the key form |
| A per-aggregation filter | aggregate path | served | same refusal as
the key form |
| `having` | guard (unit pin) | not collected | collected, same refusal
as the key form at the guard; not pinned through a route (see Acceptance
notes) |
| A member of a list operator, an endpoint of a range operator | none |
not a comparand position: the grammar refuses a reference there |
unchanged, not pinned |

A comparand naming a field the caller may read is served as before (the
control, in every position above).

## Mechanism readings

- **D1, the collector and its callers.** `collectConditionFields` is
called by itself (logical groups) and by `collectQueryFields` (`where`,
`having`, each aggregation's `filter`). `collectQueryFields` is called
only by `assertReadableQueryFields`, which has one call site: the
security plugin's engine middleware, for every verb that carries a
predicate (the read, `findOne`, `count` and `aggregate` on the caller's
query; bulk `update` / `delete` on the caller's own condition). The
package index re-exports all three; no in-repo consumer outside tests.
So the aggregate path is not a separate caller: it gets the widened
collection through the same call.
- **D2, the comparand reader.** `FieldReferenceSchema` from
`@objectstack/spec/data`, the grammar's exported declaration of a
cross-field reference (including the whole-day offset's own nested
reference). No function that yields the referenced field is exported:
the filter module's and the driver readers' reference predicates are
module-private. So each node of a field constraint is asked of the
schema itself (`safeParse`), the same reading `objectql`'s having filter
already takes. The walk carries no operator list and no position list:
it visits every node of the constraint, so a position the grammar admits
is covered without being named. `packages/spec/src`,
`packages/objectql/src` and `service-analytics` are untouched.
- **D3, positions.** The grammar admits a reference as the whole
comparand of the six scalar comparisons, with or without the whole-day
offset (whose offset may itself be a reference), under any logical
nesting, in each clause that carries a condition. It refuses one as a
list member or a range endpoint. Every admitted position is pinned.
- **D4, the refusal.** The key form is the reference: the route pins
read it first, per hidden field and per path, and require the comparand
form's status and whole body to equal it; the unit pins require equal
`code`, `status`, message and details.
- **D6, `Clause-②`.** Widening measured absent: no export added or
removed (the package index and manifest are untouched, the new helper is
module-private), and the collected set only grows, so nothing refused
before is served now. Narrowing measured present: on the pre-fix source,
all 15 route refusal pins answered 200 where the fix answers 403. Line 2
read by `scripts/pm/clause2-line.mjs` (`readClause2Line`): declared,
value `no`, arm `narrowing`. The changeset is `minor` with the BREAKING
banner and its ADR-0087 marker.

## Pins, red/green and ablation

Commits: the pins (`9e602fe84`, red on that commit by design), the fix
(`3b34899fc`), the changeset wording (`215bc699b`, HEAD). Pushed
together only after the fix was committed.

- `packages/plugins/plugin-security/src/predicate-guard.test.ts`: the
collection and the verdict, per position, with the key form as the
reference and a readable-comparand control.
- `packages/rest/src/data-field-comparand-permission.test.ts`: the real
`SecurityPlugin` on a real `ObjectQL` over a real `SqlDriver`, through
the data query route, on the data read and the aggregate path, each
refusal compared with the key form's, each position with its readable
control.

All readings below were taken on HEAD `215bc699b`.

- **Red/green on the committed tree** (`predicate-guard.ts` restored
from the pins commit, pins at HEAD, package rebuilt and the built output
proven to carry no comparand collection): unit 23 failed / 12 passed
(35), route 15 failed / 15 passed (30). Every refusal pin red, every
control green. Green leg (restored from HEAD by absolute path, blob
equal to the HEAD blob, `git diff HEAD` 0 bytes, rebuilt, fix proven
present in the built output): unit 35/35, route 30/30.
- **Ablation of the widened collection** (the collector's call replaced
by a no-op reference so the package still compiles; the mutation proven
on disk by anchor count 1 to 0 and blob change; rebuilt; built output
proven without the call): predicted unit 23 failed / 12 passed and route
15 failed / 15 passed; observed exactly that. Restored: blob equal to
the HEAD blob, `git diff HEAD` empty, rebuilt, the call present in the
built output again. A first attempt that deleted the call outright did
not build (the helper became unused), so the suite would have read a
stale build; it was discarded and is not counted.

## Tests and gates

- `pnpm --filter @objectstack/plugin-security test`: 149 files passed;
3252 passed, 23 skipped. Exit 0.
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0
(source, scripts, and the test layer under `tsconfig.test.json`, 0 debt
entries).
- `pnpm --filter @objectstack/rest test`: 244 files passed; 4895 passed,
114 skipped. Exit 0.
- `pnpm --filter @objectstack/rest typecheck`: exit 0; the new route pin
is in the program.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands` at `215bc699b`
derives 64 commands; those plus the 4 roster families named at dispatch
(`check-changeset-fixed`, `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`) were run one at
a time, each exit code captured before any pipe: 68 of 68 exit 0. A full
build (72 tasks) preceded the gates that read built output.
Reconciliation: `✓ dispatch-gates --ran: 64 derived famil(ies) accounted
for — 64 run, 0 NOT-MEASURED`.
- Lint, narrowed and proven: the population is the three changed
TypeScript files (eslint's own config applies to each; it ignores the
changeset); `eslint --no-inline-config --format json` reports 3 files, 0
errors, 0 warnings; this repo's config enables no type-aware linting, so
the diff cannot move the verdict on any untouched file. The repo-wide
`pnpm lint` is CI's.
- The client envelope-caller census needs no row: the pins call no
censused client method.

## Acceptance notes

- **`having`**: a comparand there is collected and pinned at the guard
(unit); it is NOT MEASURED through a route.
- **`findOne`, `count`, bulk `update` / `delete`**: covered by
construction (the guard's single call site serves every verb that
carries a predicate) and by the collector pins; not pinned per verb in
the committed suite.
- **Analytics**: inherits the fix through the engine path; not touched
and not pinned here. Its own gate is objectstack-ai#20917's (PR objectstack-ai#20931).
- **A comparand inside a nested-relation condition** is collected by the
same walk and judged against the queried object's own field permissions
(the refusing direction). NOT MEASURED.
- **Drivers**: the route pin runs on SQLite only; the guard runs before
any driver.
- **`main` moved**: `origin/main` is at `013f97df9`, 8 commits past this
branch's base `1571aedce` (read just before this PR opened). None
touches this claim's surface, so the branch is not merged (per the
dispatch order); this PR's CI on the merge ref reads the merged tree.
`dispatch-gates` flagged three of its derivation inputs as changed on
`main` (two ADR-anchor files for other packages and the doc-authoring
prose-id baseline); the derived family list is unchanged.
- **CI**: not awaited; the CI-only lanes `dispatch-gates` names (the
test shards, temporal conformance, dogfood, build core, the workspace
type-check lanes, the artifact-roster and wide-population families) are
CI's.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…oins the one admitted and scoped object set (objectstack-ai#20933) (objectstack-ai#20962)

Fixes objectstack-ai#20933
Clause-②: no (narrowing)

## What this changes

The analytics door asks two questions over one object set before either
strategy runs: the object-level read admission, and the read scope each
strategy applies to the objects it reads. That set held the cube's base
object and the joins the cube declares (`joins`, or a dataset's
`include`). An object a query reaches through a relationship path the
cube does not declare was not in it, although both strategies read that
object. The class: **a related object reached through a relationship
path was read without its admission or its row scope** on the native-SQL
strategy, and was not asked for at the door on the ObjectQL strategy.

Every such object now joins the one set
(`AnalyticsService.queryObjects`). It is admitted and row-scoped exactly
as the base object and a declared join are, through the same mechanism,
on both strategies and on every analytics face (the cube read, the SQL
echo and the dataset door). There is no per-strategy copy of the rule
and no scope applied only inside the native join: the strategies apply
the scope of every object they read from the set they are handed.

Three source files change, all in `@objectstack/service-analytics`:

- `analytics-service.ts`: `queryObjects` adds, per hop, the object each
member the query names reaches through a relationship path. The hop's
object is read from `namedQueryFields`, the field gate's own resolution
(PR objectstack-ai#20931), reused rather than re-derived. `callCtx` derives the set
once and hands the same value to the admission and to the read-scope
pre-pass, and passes it to the strategy as `readScopedObjects`.
- `strategies/native-sql-strategy.ts`: the cross-field decline (a read
scope carrying a field reference routes the query to the engine path)
reads the scopes over the door's set, rather than re-deriving base plus
declared joins.
- `strategies/types.ts`: declares `readScopedObjects` on the
package-internal `DatasetScopedStrategyContext`. It is not exported: the
built declaration file carries no hit for it.

## Per position (disclosure-safe: classes, no request shapes)

Reference: the ObjectQL strategy's answer, and the same question asked
through a declared join to the same object.

| Position | Strategy | Before (class) | After |
|---|---|---|---|
| Inferred cube: a dimension through a relationship path | native |
related object read with no admission and no row scope | unreadable
related object: `403 PERMISSION_DENIED` naming it, before any statement
runs; related rows outside the caller's scope are not read |
| | ObjectQL | refused by the engine's own admission (its generic
refusal); the door never asked | the door's refusal naming the object
(same code and status, differs in form); scope unchanged (rows outside
it grouped as restricted) |
| Inferred cube: a filter member through a path | native | filtered on
related values with no admission and no row scope | refused as above; a
related value outside the scope matches nothing |
| | ObjectQL | `400 INVALID_FIELD` (the strategy cannot filter across
objects) | unreadable related object: the door's `403`; otherwise the
same `400` |
| Inferred cube: a time-dimension window through a path | native |
windowed on related values with no row scope | refused, or scoped, as
above |
| | ObjectQL | `400 INVALID_FIELD` | unreadable related object: the
door's `403`; otherwise the same `400` |
| Authored cube: a dimension or measure over a relationship its `joins`
does not list | native | read with no admission and no row scope |
refused, or scoped, as a declared join is |
| | ObjectQL | engine refusal / engine scope | the door's refusal; scope
unchanged |
| Authored cube: a path the query names itself | both | as the two rows
above | as the two rows above |
| Two-hop path (first hop falls back to its alias, second hop keyed by
the cube's join) | native | the first hop's object read with no
admission and no row scope | each hop admitted and scoped on its own
object |
| | ObjectQL | `400 INVALID_FIELD` (single hop only) | an unreadable
hop: the door's `403`; otherwise the same `400` |
| SQL echo of any row above | both | statement echoed with no admission
of the related object | refused as the read is; on the native strategy
the echoed statement carries the related object's scope clause, as a
declared join's does |
| Dataset door: a related object named through a relationship the
dataset does not declare | native | `400 DATASET_INVALID` (the native
compile's own refusal) | unreadable related object: the door's `403`;
otherwise the same `400` |
| | ObjectQL | engine refusal | the door's refusal |
| Control: a readable related object | both | answered | answered,
within the caller's row scope |

**E4, native "after" against the reference.** The native answer now
equals the native answer for the same question through a declared join,
exactly: the same refusal envelope, and the same scope clause on the
joined object. Against the ObjectQL reference it **differs in form** on
out-of-scope related rows only. Native applies the joined object's scope
as a predicate over the join (ADR-0021 D-C), so a base row whose related
record is outside the caller's scope drops out of the answer. ObjectQL
groups such a row as restricted. Neither reads the related value. This
is the pre-existing declared-join form on each strategy, and this PR
does not change it.

## Mechanism readings (E1 to E3)

- **E1, the set** (`analytics-service.ts` at HEAD: admission `:1449`,
`queryObjects` `:1575`, `cubeObjects` `:1594`, `resolveReadScopes`
`:1683`). At base `1571aedce`, `queryObjects` returned
`cubeObjects(cube)`: the base object plus every declared join.
- An inferred cube's relationship path: the base object only. The
admission asked for one object, and the scope pre-pass resolved one
object.
  - An authored cube's declared join: base plus the join's object.
- A dataset's `include`: base plus each included object, since the
compiled dataset carries it as a declared join.
- After the fix, each case also carries every hop's object. Admission
and the scope pre-pass read the same value, and a unit pin asserts that
the two sets are equal.
- **E2, the native join.** `qualifyAndRegisterJoin`
(`native-sql-strategy.ts:740`) registers one join per path prefix,
aliased by the path with dots as `__`. The build loop (`:617`–`:624`)
already applied the read scope for the base object and for every
registered join, whether synthesized or declared, through
`applyReadScope`. It reads `getReadScope` for that join's object: the
same mechanism a declared join uses.
- Before the fix, the pre-resolved scope map had no entry for a path
object, so `getReadScope` answered nothing and no predicate was applied.
- The fix changes the set and leaves the application path alone. The one
strategy line that re-derived the object list, the cross-field decline
(`:340`), now reads the door's set.
- **E3, the object a path reads.** Hop by hop, through
`namedQueryFields` (PR objectstack-ai#20931's resolution): the cube's join keyed by
the path with dots as `__`, falling back to the alias itself. Pinned on
a two-hop path whose first hop falls back and whose second is keyed: the
admitted set is exactly base, first hop and second hop. The route pin
serves the two-hop case on the native strategy. The ObjectQL strategy
serves a single hop only.
- **E5, containment.** The fix lands within this card on both
strategies, so routing restricted callers through the ObjectQL strategy
was not needed and is not proposed.
- **E6, `Clause-②`.** Measured in both directions.
  - Widening: none.
- The public type surface is unchanged: `readScopedObjects` has 0 hits
in the built `dist/index.d.ts`, against 19 for `StrategyContext` as a
positive control, and the context type is not exported.
    - No probed position moved from refused to answered.
- The native decline set is a superset of what it was: the same base and
declared-join derivation, plus the path objects.
- Narrowing: yes. Positions move from answered-unadmitted to `403`, and
from unscoped to scoped.
- Line 2 reads `declared · no · narrowing` through
`scripts/pm/clause2-line.mjs`. The changeset is `minor`, carries the
BREAKING banner, and carries its ADR-0087 marker (`not-required
(no-migration-prescription)`).

## Pins, red and green, and ablation

- **Unit pin**
`packages/services/service-analytics/src/__tests__/relationship-path-admission.test.ts`
(27 cases), on both strategies. It covers the seven positions above, and
for each one checks three things:
- an unreadable related object is refused by name, on the read and on
the echo, before anything runs;
- admission and the scope pre-pass ask for one set, and that set carries
the path's object;
  - the related object's scope reaches what each strategy executes.

It also carries the two-hop resolution, the native decline, and the
control.
- **Route pin**
`packages/rest/src/analytics-relationship-path-admission.test.ts` (28
cases). It runs over the shipped composition with the real
`SecurityPlugin`, `ObjectQL` and `SqlDriver` (SQLite), once per
strategy, and every answer is compared with the same question through a
declared join. It covers:
  - an unreadable related object is refused;
  - related rows outside the caller's scope are not read;
  - a filter on an out-of-scope related value counts nothing;
  - the control;
  - the dataset door.
- **Red before the fix.** Pins were committed at `9c12bad1c`, before the
fix at `b48c42e1a`, and pushed only together with it. On the pins tree:
unit 25 failed, 2 passed (the controls); route 20 failed, 8 passed (the
fixture checks, the controls, and the ObjectQL positions that were
already right). Ablation 1 below reproduces exactly that red set from
the committed HEAD.
- **Green at HEAD `fdbdc670d`.** Unit 27/27, route 28/28, read after a
rebuild with the marker present in `dist/`.
- **Ablation 1, the widened set.** The path-object loop in
`queryObjects` was deleted through `scripts/ablation-replace.mjs`, which
confirmed the anchor went from 1 to 0 and the blob changed. The package
was rebuilt, and `ablation-dist-preflight --absent` confirmed the marker
was gone from `dist/`.
- Result: unit 25 failed / 2 passed, route 20 failed / 8 passed.
Predicted in writing beforehand, and exactly the pre-fix red set.
- Restore: the blob equals HEAD (`7e80788d9873`), `git diff HEAD` is
empty, and porcelain is empty, re-proved by an outer trap by absolute
path. After a rebuild the marker is present in `dist/` again, and the
pins read unit 27/27, route 28/28.
- **Ablation 2, the decline reads the door's set.** The strategy was
made to ignore `readScopedObjects`. Result: unit 1 failed / 26 passed,
exactly the decline pin, as predicted. Restore: the blob equals HEAD
(`bc6e15abfc96`), `git diff HEAD` is empty, and the unit pin reads 27/27
afterwards. The unit pin imports source, so no build was involved.

## Tests and gates (HEAD `fdbdc670d`)

- **Gate union, one locked sequential run on HEAD `fdbdc670dc`** (`git
rev-parse --short HEAD`, printed by the run at start and end with an
empty `git diff HEAD`). The run covered the 62 commands `dispatch-gates
--commands` derives from this diff: 61 exited 0, and 1 is NOT MEASURED.
- `pnpm check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET. The
gate reads every workspace package's built output, and 34 packages have
no `dist/` in this worktree. For the one package this diff changes, a
direct `require()` of its CommonJS entry loads, with 17 exports. CI runs
the gate itself.
- `dispatch-gates --ran` reconciliation: 62 derived, 61 run, 1 NOT
MEASURED, 0 unrun (exit 0).
- **The four roster families named at dispatch** are not printed by this
diff's derivation. They were run anyway, and all exited 0: `node
scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm
check:error-code-casing` and `pnpm check:filter-alias-parity`.
- **Package suites at HEAD `fdbdc670d`.**
  - `@objectstack/service-analytics`: 147 files, 3401 tests passed.
- `@objectstack/rest` (`--project local`): 244 files, 4893 passed, 114
skipped.
  - `@objectstack/client`: 50 files, 641 passed.
- **The client census.** `envelope-caller-census.test.ts` is 20/20.
Neither pin adds a censused call site: the census's own pattern has 0
hits in each pin, against 6 in a positive-control file. No ledger row is
needed.
- **Typecheck.** `@objectstack/service-analytics` and
`@objectstack/rest` (its test-layer typecheck included) exit 0. Both pin
files are in their package's typecheck program, counted with
`--listFiles`.
- **eslint, narrowed.** The whole-repo run is CI's. The 5 changed
TypeScript files give 0 errors and 0 warnings, and eslint's own JSON
output reports all 5 and none as ignored. The config enables no
type-aware linting, so this diff cannot move an untouched file's
verdict.
- **Build.** The dependency closure and `@objectstack/service-analytics`
at HEAD built with exit 0.

## Acceptance notes

- **Native and ObjectQL differ in form on out-of-scope related rows**
(E4 above). Native drops the base row, and ObjectQL groups it as
restricted. This is pre-existing on declared joins, and this PR leaves
it unchanged.
- **A relationship name that is not itself an object name** was never
served: a 500 on the native strategy, and an engine refusal or a `400`
on the ObjectQL strategy. For a caller the object-level check applies
to, it now answers the door's `403` naming that relationship. That is
the resolution PR objectstack-ai#20931's field gate already uses. The changeset states
it.
- **The native decline's fallback.** The decline re-derives base plus
declared joins only for a strategy context built without the door's set.
The door always passes the set whenever it resolves read scopes, so no
production path reaches the fallback. NOT MEASURED by a pin.
- **The draft-preview branch** keeps using `cubeObjects` alone, because
it evaluates the executor's queries over the drafted base rows in
memory. NOT MEASURED by a pin here.
- **A cube whose `sql` is not a bare object name** names no attributable
field (`namedQueryFields`), so its set is unchanged by this PR.
- **Drivers.** The route pin runs on SQLite only. The set is computed at
the door, before any driver.
- **`main` moved.** `origin/main` is at `f6ccca4a4`, 11 commits past
this branch's base `1571aedce` (read just before this PR opened). None
of them touches this claim's surface, so the branch is not merged, per
the dispatch order. This PR's CI on the merge ref reads the merged tree.
- `dispatch-gates` flagged three of its derivation inputs as changed on
`main`: two ADR-anchor files for other packages, and the doc-authoring
prose-id baseline. This branch adds tracker ids only in code comments,
and the derived family list is unchanged.
- **CI** is not awaited. The CI-only lanes `dispatch-gates` names (the
test shards, dogfood, temporal conformance, build core, the workspace
type-check lanes and the wide-population families) are CI's.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… answer on every analytics face — the related object read as the caller, capped (objectstack-ai#20887) (objectstack-ai#20916)

Fixes objectstack-ai#20887
Clause-②: yes (narrowing)

The analytics half of ruling 5907789183, whose parent card is objectstack-ai#20802
(its engine half landed as objectstack-ai#20872, `ca5408c62`). The nested-relation
filter `{ relation: { field: value } }` now gets ONE answer on every
analytics face, and it is the engine's: the related object read as the
caller (its row scope and field permissions), capped at
`RELATION_FILTER_ID_CAP`, a multi-valued relation matching on any
member. The analytics layer holds no copy of that rule. The native-SQL
strategy declines a query carrying the form, and the engine-aggregate
strategy hands the form to the engine as written.

## Per face

The engine's answer for the same filter, computed in the same test over
the same rows, is the reference for every cell. Fixture: a ledger with
`owner` (lookup) and `owners` (multiple lookup) to an owner object; the
member cannot read `owner.secret`, and its row scope hides owners in
region `HIDDEN`. Past the cap means 1,001 matching owners. Measured with
the real `SecurityPlugin`, `ObjectQL` and `SqlDriver` (SQLite).

| face | strategy | engine rows equal | caller permissions | cap |
|---|---|---|---|---|
| cube read, `POST /api/v1/analytics/query` (`AnalyticsService.query`) |
NativeSQL composition (declines, so the engine answers) | yes: single,
multi, related row scope, `$not`, `$or` (was 500 `DATABASE_ERROR`: the
join named a table `owner` that does not exist) | 403
`PERMISSION_DENIED`, as the engine (was 500) | 400 `INVALID_FILTER`, as
the engine (was 500) |
| cube read | ObjectQL | yes (was 400 `INVALID_FIELD`, "cannot evaluate
a cross-object filter") | 403 (was 400) | 400 (was 400 cross-object) |
| dataset door, `POST /api/v1/analytics/dataset/query`, the dataset
`include`s `owner` | NativeSQL composition | yes (was: single-valued
rows via the JOIN; multi-valued 400 `DATASET_INVALID`; under `$not` the
member got `b` where the engine answers `b, d`) | 403 (was **200 with
rows a, c**: filtered by a field the caller cannot read) | 400 (was 200
with no rows) |
| dataset door | ObjectQL | yes (was 400) | 403 (was 400) | 400 (was
400) |
| a measure's own `filter` carrying the form | both | refused 400
`INVALID_FILTER`, as the engine refuses it at an aggregation's `filter`
(was: native counted it through the JOIN; ObjectQL 400 `INVALID_FIELD`)
| n/a | n/a |
| SQL echo, `POST /api/v1/analytics/sql` | both | refused 400
`INVALID_FILTER`, naming the served route (was: native printed the JOIN;
ObjectQL 400) | n/a | n/a |
| a read scope carrying the form (a host `getReadScope`) | NativeSQL |
refused 500 `READ_SCOPE_COMPILE_FAILED`, policy withheld, words now
naming the route (outcome unchanged) | n/a | n/a |
| a read scope carrying the form | ObjectQL | served, the engine's rows
(unchanged: the scope reaches the engine as written) | as the caller |
the engine's |

## Mechanism assumptions, measured

- **B1 held.** The engine's answer for the fixture, as the member: `{
owner: { region: 'NA' } }` is d1, d3; the multi-valued form is d1, d3;
`{ owner: { secret: 's1' } }` is 403 `PERMISSION_DENIED` naming `secret`
(a system caller gets d1, d3); region `HIDDEN` gives no rows (a system
caller gets d4); past the cap is 400 `INVALID_FILTER` for both
spellings; `$not` gives d2, d4; the `$or` gives d1, d2, d3; `{ owner: {}
}` and a second level are 400. `RELATION_FILTER_ID_CAP` is exported
(`packages/objectql/src/index.ts:148`), and nothing here imports it: the
analytics layer never counts ids, the engine does.
- **B2: no on every axis**, per the table. The native path joined the
related table itself: the related row scope rode in as a `WHERE`
conjunct, the field permissions did not, nothing bounded the match, and
a multi-valued relation or an undeclared join failed. The ObjectQL path
refused the form outright.
- **B3: call the engine.** `@objectstack/objectql` exports only the cap.
The lowering (`admitRelationCondition`, `lowerRelationSite`) is
module-internal, and this package has `@objectstack/objectql` as a dev
dependency only. The route that needs no export: the engine-aggregate
strategy hands the condition to `engine.aggregate` through
`executeAggregate`, with the caller's context. No export was needed, and
there is no second permission rule.
- **B4: the read scope keeps its refusal, and the words name the served
route.** `compileScopedFilterToSql` is a synchronous string builder. It
holds the caller's `ExecutionContext` for placeholders only, and no data
engine, so its compile cannot run the inner read as the caller. Routing
a read scope carrying the form to the engine instead was built and
measured, then withdrawn: on a native-only host it traded the declared
`READ_SCOPE_COMPILE_FAILED` (policy withheld) for a generic no-strategy
fault (`packages/rest/src/analytics-read-scope-refusal-envelope.test.ts`
went red). No in-repo producer emits the form in a scope: the RLS
compiler refuses a relation traversal when it compiles the policy. On
the ObjectQL path the scope reaches the engine as before, and the engine
serves it as the caller.
- **B5: `yes (narrowing)`.** Widening: the cube read (both strategies),
the ObjectQL dataset door, a multi-valued relation and a dataset without
the declared join on the native path, and a dataset's own `filter` on
the ObjectQL path all now serve the form (they answered 500 or 400).
Narrowing: on the native path the dataset door now refuses a condition
on a related field the caller cannot read (was rows), a match past the
cap (was an empty 200), and a measure filter carrying the form (was a
count). The SQL echo refuses the form. And a query combining the form
with something only the native strategy serves (a cross-object measure,
a multi-hop dimension) is refused by the engine-aggregate path.
`@objectstack/service-analytics` ships `minor` with the BREAKING banner
and an ADR-0087 `not-required (no-migration-prescription)` disposition;
`check-adr-0087-registration` and `check-changeset-no-major` pass.
- **B6: no page to update.** No hand-written `content/docs/**` page
states how the analytics read or the read scope treats the nested form.
`data-engine.mdx`, and `query-syntax.mdx` (objectstack-ai#20906, which landed during
this work), describe the engine only.

## What changed

- `strategies/filter-normalizer.ts`: a nested-relation condition becomes
a `relation` node carrying the condition as written. It is no longer
flattened to the dotted member. `shieldNestedRelations` holds it out of
the shared lowering, because under `$not` the lowering guarded the
relation column, and this package's engine hand-off spells that guard
`$ne: null`, which `driver-sql` refuses over a multi-valued JSON column.
Measured: the multi-valued `$not` pin went red before the shield, and
the engine guards what it lowers the condition to itself.
`findNestedRelationCondition` is the routing detector.
- `strategies/native-sql-strategy.ts`: `canHandle` declines when the
`where`, the dataset's own `filter` or a requested measure's `filter`
carries the form. This is the mechanism of the cross-field decline
(maintainer ruling 2026-08-12, Q1 = B). Its compiler refuses a
`relation` node bare, as routing drift.
- `strategies/objectql-strategy.ts`: the condition goes to the engine as
its own conjunct, under the key the author wrote. The display-SQL echo
declines it.
- `read-scope-sql.ts`: the nested-relation form's refusal has its own
words, naming the route. An empty or mixed value object keeps the old
words.
- `analytics-service.ts`: the no-strategy error names the
nested-relation decline.
- The mixed-wrapper refusal no longer says a nested member "compiles to
the dotted member".

## Pins, red first (`568727629`)

- `packages/rest/src/analytics-nested-relation-filter.test.ts`: both
compositions, the cube read and the dataset door through its route,
against the engine's answer. It was red 10 of 10 on the base, and is 10
of 10 green now.
-
`packages/services/service-analytics/src/__tests__/nested-relation-engine-handoff.test.ts`:
the native decline per producer, the ObjectQL hand-off as written, the
compile backstop and the read-scope words. It was 7 red with 2 controls
green on the base, and is 9 of 9 green now.

## Ablations, predicted before running, at `5bb764181`

Each ablation mutated the committed file through
`scripts/ablation-replace.mjs` (anchor hit once, blob moved), rebuilt
`@objectstack/service-analytics`, and passed `ablation-dist-preflight`
(the marker present in 2 built files). It then ran both pin files, plus
`where-door-shared-lowering-seam.test.ts` in the unit run. The restore
leg proved the blob equal to HEAD and `git diff HEAD` empty, rebuilt,
and found the marker absent from all 6 built files. Every observed count
equals its prediction.

| ablation | face it guards | unit (27) | route pins (10) |
|---|---|---|---|
| A1 the native decline removed | cube read and dataset door, native | 4
red | 4 red (native: rows, refusals, measure filter, echo) |
| A2 the hand-off drops the condition | both strategies' rows,
permission, cap | 2 red | 6 red |
| A3 the aggregate call forwards no caller context | caller permissions
| 0 | 4 red: the member then saw `d` (a hidden owner's row), and the
unreadable field answered rows |
| A4 the read-scope route words | read scope, native | 1 red | 1 red |
| A6 the lowering shield removed | multi-valued `$not` | 1 red | 2 red |
| A7 the echo refusal removed | SQL echo | 0 | 2 red |

A first round at `dca1af7cb` matched its own predictions too, including
A5, the read-scope decline arm, which B4's correction removed from the
code.

## Pins re-judged

These pins recorded the flattening this change removes, so each was
re-judged:

- respelled to the dotted cube member where the pin was about the
traversal: `filter-normalizer-not-null-safe`,
`icontains-text-comparand-refusal`;
- re-expected as a `relation` node where the pin was about acceptance:
`where-equality-slot-list-refusal`, `where-face-arms-refusal`,
`where-type-face-refusal`, `filter-normalizer-mixed-wrapper`'s
pure-shape block;
- replaced where the pin held the removed branch: mixed-wrapper row 6
and its `guardFieldEntry` recursion row (the engine refuses that inner
wrapper, `INVALID_FILTER` / 400, measured),
`where-door-shared-lowering-seam`, `infer-cube-relation-traversal`,
`infer-cube-where-spelling-parity`, and `where-source-field-gate`, which
now judges the relation field `owner` as a column of the queried object;
- re-worded to the read-scope refusal's new words: `read-scope-sql`,
`read-scope-not-null-safe`, `read-scope-undefined-comparand`, and
`read-scope-refusal-envelope`, which gains row 17 because the
nested-relation form now has a throw site of its own.

## Verification

- `@objectstack/service-analytics`: `test` 146 files, 3334 passed;
`typecheck` exit 0, with 146 of 146 test files in the tsc program
(`--listFiles`). Both at `4d383dac0`, after merging `main`.
- `@objectstack/rest`: the full `local` project, 239 files, 4662 passed
and 106 skipped, at `5bb764181`. The merge brought no rest or analytics
change. At `4d383dac0`, the new pin, the read-scope envelope pin and the
engine half's permission pin: 3 files, 21 passed. `typecheck` passes,
including the test layer (`check:test-typecheck` OK).
- Consumer sweep, narrowed to the files that load this package:
`@objectstack/runtime` `analytics-*` plus
`cross-field-refusal-operand-withhold`, 5 files, 38 passed and 4
skipped; `@objectstack/client` `analytics-automation-json-erasure`, 7
passed.
- Gates at `4d383dac0`: `dispatch-gates --commands` derived 62. All 62
were run, plus the 4 roster families (`check-changeset-fixed`,
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`), all exit 0. `dispatch-gates --ran`: 62
derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. `check:dual-build-cjs-loads`
and `check:type-check-debt` first exited 3 (prerequisite not met) and
were re-run green after `turbo run build` over `./packages/*`.
- Lint, narrowed and proved at `4d383dac0`. The population is the 21
changed `.ts` files, none ignored by eslint's own config
(`isPathIgnored` false for all 21). `eslint --no-inline-config --format
json` over them gives 21 files, 0 errors, 0 warnings.
`parserOptions.project` and `projectService` are unset for all 21, so no
type-aware lint runs and no untouched file's verdict can move.
- NOT MEASURED: a live PostgreSQL cell (the dialect axis of the lowering
is the engine's, pinned by objectstack-ai#20872's `data-nested-object-door.test.ts`;
this file's axis is the analytics faces), the dogfood and integration
lanes, and the whole-workspace typecheck. All are left to CI.

## Acceptance notes

- The ObjectQL path's cross-object refusal still says "Run this query on
a native-SQL driver". A query the form routed away from the native
strategy can meet those words on a SQL deployment.
- The engine's cap refusal names the position the engine received. The
strategy ANDs the condition in, so the words read `where.$and[0].owner`
where the author wrote `where.owner`.
- `packages/types/src/error-leak.test.ts` keeps a hand-written stand-in
of the read-scope refusal shapes. Its nested-relation line is the old
wording. It is a heuristic fixture, not a pin of this module, and it
stays green.
- `MemoryAnalyticsService` (driver-memory's cube face, objectstack-ai#20859's
position) is not touched, and its answer for the form is not measured
here. The draft preview refuses the form as an operator it cannot
evaluate, unchanged.

## Patch rounds (the seat's append from the dev's reports `5917211340`,
`5917807320` and `5918233769`; the dev writes a body only once)

### Patch round 1

`Test Core (3/6)` went red on `4d383dac0`, in `packages/client`'s
`envelope-caller-census.test.ts`: 2 of its tests failed. Reproduced
locally: the client suite fails 1 file and 2 tests at `4d383dac0`, and
passes 50 files and 641 tests at the merge base `9509ea106`.

**Root cause.** The census walks the whole workspace for call sites of
`analytics.query(` and requires a hand-ledger row for each one. This
PR's new pin,
`packages/rest/src/analytics-nested-relation-filter.test.ts`, calls the
real `AnalyticsService`'s `analytics.query` five times. Those are
producer reads, the census's `NOT_SDK` class, and the ledger has no row
for them. The failing assertions are the §3 key comparison (the one
extra key is that file, `analytics.query`, `service`, 5) and the §2
producer-receiver count (1 expected, 6 found).

**What it is not.** It is not a product defect. It is not a client pin
of the nested-relation form or of the read-scope wording either.

**The fix, pending the seat.** It lives in
`packages/client/src/envelope-caller-census.test.ts`, which is outside
this card's claim surface. It adds one `NOT_SDK` ledger row with a count
of 5, and moves the two producer-read counts from 1 to 6. Measured on a
scratch copy of that file: 20 of 20 tests passed. The copy was restored
byte-identical, and nothing was committed.

### Patch round 2

The seat authorised the census remedy, round 1's option A, for one file:
`packages/client/src/envelope-caller-census.test.ts`.

- **main moved.** `975b2481c` (objectstack-ai#20808) touched
`packages/services/service-analytics`, so `origin/main` was merged into
the branch as `1fdaff7e5` (no rebase). The merge was clean, with no
regeneration pending.
- **The census change is its own commit, `7eb2ecf20`.**
- It adds one `NOT_SDK` ledger row for
`packages/rest/src/analytics-nested-relation-filter.test.ts`
(`analytics.query`, `service`, count 5).
- The producer-receiver count goes from 1 to 6, and the set of two files
is asserted.
- `verdictTotal('NOT_SDK')` goes from 1 to 6, and its test title changes
with it.
  - Nothing else in that file changed.
- **Measured at `7eb2ecf20`.** Each exit code was captured before any
pipe.
- `pnpm --filter @objectstack/client test`: exit 0, 50 files and 641
tests passed. The census file run alone passed 20 of 20.
- `pnpm --filter @objectstack/client typecheck`: exit 0. `tsc --noEmit`
passed, and `check:test-typecheck` answered OK.
- `@objectstack/service-analytics` test: exit 0, 146 files and 3335
tests passed. Its typecheck: exit 0.
- The three rest files (`analytics-nested-relation-filter`,
`analytics-read-scope-refusal-envelope`,
`data-nested-relation-permission`): exit 0, 3 files and 21 tests passed.
- ESLint over the 22 changed `.ts` files: 0 errors and 0 warnings. The
config ignores none of them and lints none type-aware, so this diff
cannot move a verdict on an untouched file.
- Gates, re-derived: 63 derived and 63 run, 0 not measured, plus the 4
roster families. All exit 0 except one.
- **The one red is `check:cross-package-test-inputs`.**
- Cause: the new ledger row spells the rest pin's path as a literal, and
`@objectstack/client`'s declared cross-package input globs do not cover
it. The gate is green at `1fdaff7e5`, the commit before.
- The gate's own remedy: declare that one file in
`scripts/cross-package-test-inputs.mjs`, and mirror it in `turbo.json`'s
`@objectstack/client#test` inputs.
- Measured on the working tree: that gate and `check-ci-filter-parity`
both exit 0. The two files were then restored byte-identical.
- Both files lie outside the authorised surface, so the remedy waits for
the seat.

### Patch round 3

The seat authorised the gate's own remedy for
`check:cross-package-test-inputs`, in two files.

- **main.** No commit since `975b2481c` touched this card's surface, the
census or either of the two files, so there was no merge. The commits
checked were `def279a39`, `4d0b9cd54` and `d78a0bda0`.
- **The declaration is its own commit, `2881f478c`.**
- `scripts/cross-package-test-inputs.mjs`: in `@objectstack/client`'s
entry, one per-file glob,
`packages/rest/src/analytics-nested-relation-filter.test.ts`, with a
3-line comment.
- `turbo.json`:
`$TURBO_ROOT$/packages/rest/src/analytics-nested-relation-filter.test.ts`
in `@objectstack/client#test`'s inputs. The line before it gains the
comma JSON requires.
  - Nothing else changed in either file.
- **Measured at `2881f478c`.**
- Gates, re-derived: 81 derived and 81 run, 0 not measured. Also run:
the 11 roster families whose roster lies under a path this diff touches,
`check-ci-filter-parity --self-test` and `check:select-shard-packages`.
All 93 commands exit 0.
- `check:cross-package-test-inputs` (with `--self-test`) is green: "OK:
29 package(s) read outside themselves, all declared".
- `check-ci-filter-parity` is green: "all 188 declared cross-package
glob(s) (135 unique) are covered".
    - `check:turbo-task-graph` is green.
- `pnpm --filter @objectstack/client test`, as the control: exit 0, 50
files and 641 tests passed, the same as at `7eb2ecf20`. The declaration
moved no verdict.
- Layer A at work: `--union-into`, given a diff of the rest pin alone,
now pulls `@objectstack/client` into the run (8 packages). At
`7eb2ecf20` it did not (7 packages). No other package changed.
- Turbo hashes, from `--dry=json` before and after, over build, test,
test:repo and typecheck (303 tasks):
- The global hash is unchanged, and no build or typecheck hash moved.
- 7 test hashes moved. `@objectstack/client#test` moved through
`turbo.json`: its task definition changed, and the rest pin is a new
input.
- The other 6 moved only because the content of
`scripts/cross-package-test-inputs.mjs`, an input they declare, changed.
They are `cli#test`, `plugin-auth#test`, `vitest-filter-preflight#test`,
`objectql#test:repo`, `runtime#test:repo` and `spec#test:repo`.
- ESLint over the 23 changed `.ts` and `.mjs` files: 0 errors and 0
warnings. The config ignores none of them and lints none type-aware.

### Patch round 4 (the seat's append from the dev's report `5922062971`)

`main` was merged (no rebase) to take in four landings in
`service-analytics`:

- PR objectstack-ai#20931: the field-read gate at the door.
- PR objectstack-ai#20955: the queryable-field gate.
- PR objectstack-ai#20954: `plugin-security`'s comparand guard.
- PR objectstack-ai#20962: relationship-path objects in the admitted and scoped set.

**The merge, `6b6bffb3e`.** It is clean at the text level, in
`analytics-service.ts` and in `native-sql-strategy.ts`. Every line
either side added is present in the merged files, checked line by line.

**What the landed gate and object set do with the `relation` node.**
This was measured on the merged tree, in the shipped composition (the
real `SecurityPlugin` over `ObjectQL` on SQLite), under both strategies.

- **The gate judges the relation field.** `collectFilterLeaves` yields
the nested form's relation field as its member: `{ owner: { region: 'NA'
} }` gives `owner`, with operator `relation`. It does the same under
`$not` and inside `$or`. So the field gate judges the relation field on
the base object.
- A caller who may not read `owner` is refused by the gate: 403
`PERMISSION_DENIED`, in the engine's own words, with no engine call
made.
- **The related object does not enter `queryObjects`.** The security
service is asked only about the base object.
- **The engine guards the related object instead.** The nested form is
served on the ObjectQL path, where the engine reads the related object
as the caller. Each of these is refused with the same code, status and
words as `engine.find`, and never answered:
  - a related field the caller cannot read: 403;
  - a masked related field (objectstack-ai#20935): 403;
  - a related object the caller cannot read at all: 403.
- **Before this branch, the answer was a refusal.** On `main` alone,
even a readable nested condition was refused 403, "reading "owner" is
not permitted", because the flattened `owner.region` named the relation
field as an object to admit. With this branch, the answer is the
engine's.

**Measured at `6b6bffb3e`.** Every run was under the shared lock, with
each exit code captured before any pipe.

- `@objectstack/service-analytics`: tests exit 0 (149 files, 3434
tests), and typecheck exits 0.
- The five rest route pins pass 61 of 61:
  - `analytics-nested-relation-filter`: 10
  - `analytics-read-scope-refusal-envelope`: 8
  - `data-nested-relation-permission`: 3
  - `analytics-field-permission-gate`: 12
  - `analytics-relationship-path-admission`: 28
- The client census passes 20 of 20.
- Gates, re-derived and run as one locked sequential script: 81 derived,
81 run, 0 not measured. Also run: the 11 roster families and 2 extras.
All 93 commands exit 0.
- ESLint over the 23 changed `.ts` and `.mjs` files: 0 errors and 0
warnings.

**Acceptance notes.**

- **A dotted path on an inferred cube is still refused.** `{
'owner.region': 'NA' }` is refused 403, "reading "owner" is not
permitted". The hop object is taken from the alias, because an inferred
cube declares no join. This is the same on `origin/main`, and it is
outside this card; it went to the seat as a finding.
- **A host read scope does not reach the related object.** The related
object is not in `queryObjects`, so a host-supplied `getReadScope` is
not asked about it. In the shipped composition that provider is the
security service's `getReadFilter`, the same row scope the engine
applies when it reads the related object as the caller. That case is
pinned in `analytics-nested-relation-filter` ("the related row scope").

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… to the SQL echo on either strategy, and anchor the nested-relation note to its measured base (objectstack-ai#21012)

Part of objectstack-ai#20917
Clause-②: no
Part of objectstack-ai#20933 · Part of objectstack-ai#20887

A follow-up on three landed cards. It corrects one sentence in each of
three release notes of `@objectstack/service-analytics` that are still
pending on `main`, before release PR objectstack-ai#20639 consumes them. The order is
this lane's dispatch note on objectstack-ai#20917. The reasons are the `domain:spec`
seat's pointer `5922364600` on objectstack-ai#6021 (two banners) and the named
follow-up in section ② of the delta review `5922352898` on PR objectstack-ai#20916
(one paragraph). PR objectstack-ai#20991 is the precedent: it makes the same banner
correction to objectstack-ai#20935's note, and that note is not touched here. No code,
test or other file changes.

## The three sentences

| Note | Before | After |
|:---|:---|:---|
| `.changeset/20917-analytics-field-permission-gate.md`, the BREAKING
banner | "BREAKING for analytics queries on a SQL deployment that read a
field the caller may not read." | "BREAKING for analytics queries that
read a field the caller may not read: on a SQL deployment, and on `POST
/api/v1/analytics/sql` whichever strategy serves the cube." |
| `.changeset/20933-analytics-relationship-path-admission.md`, the
BREAKING banner | "BREAKING for analytics queries on a SQL deployment
that read a related object through a relationship path the cube does not
declare." | "BREAKING for analytics queries that read a related object
through a relationship path the cube does not declare: on a SQL
deployment, and on `POST /api/v1/analytics/sql` whichever strategy
serves the cube." |
| `.changeset/20887-analytics-nested-relation-engine-answer.md`, the
first sentence of **Why** | "Measured on the base over one fixture with
the real security layer (…)." | "Measured on the base before the
field-level gate (objectstack-ai#20917) and the relationship-path admission (objectstack-ai#20933)
landed, over one fixture with the real security layer (…)." |

The parenthesis in the third row is unchanged and elided here. Both
banners keep their bold. Everything else in the three files is
byte-identical: front matter, summary line, `Clause-②` line, ADR-0087
disposition marker and every other paragraph. Each file's diff is one
line (+1/−1).

## The readings behind each sentence

### Banner of the objectstack-ai#20917 note

**Before, by source, at `95555e71` (the parent of objectstack-ai#20931's landing
`1571aedc`):**

-
`packages/services/service-analytics/src/analytics-service.ts:2305-2333`:
`generateSql()` runs `callCtx` (2329), resolves a strategy (2330) and
returns that strategy's `generateSql` (2333).
- `analytics-service.ts:1283-1327`: `callCtx` runs the object-level
admission (1308) and the read-scope pre-pass. It has no field-level
gate. No non-test source in the package names `getReadableFields` at
that commit (`git grep` exit 1). The control is the landing `1571aedc`,
where it is named in three files.
- `strategies/objectql-strategy.ts:372-638`: the ObjectQL strategy's
`generateSql` renders the statement from the cube definition and returns
it (638). It makes no engine call, so it never reaches the engine's
field guards. That strategy's `execute()` reaches `engine.aggregate`,
which is why the query door already refused on it.
- So for a field the caller may not read, the echo printed a statement
on the ObjectQL strategy as well.

**After, on `main` at `a5bce40888`:**

- `analytics-service.ts:1453-1515`: `callCtx` runs the field-level gate
(1487) after the object admission (1481).
- `generateSql()` calls `callCtx` before it resolves a strategy (2580).

### Banner of the objectstack-ai#20933 note

**Before, by source, at `83480c6a` (the parent of objectstack-ai#20962's landing
`5f6b63a6`):**

- `analytics-service.ts:1452-1507`: `callCtx` admits objects over
`queryObjects` (1477). That set is `cubeObjects` (1584-1588, 1596-1608):
the base object and the declared joins only. An object reached through
an undeclared relationship path is not in it.
- The field-level gate (1483) asks the security service's
`getReadableFields`. That answer is field-level only and never asks
about object-level read
(`packages/plugins/plugin-security/src/security-plugin.ts:5410-5412`,
`computeReadableFields` 5440-5461 over `resolveProjectionFieldMask` 5517
ff.). Object-level read is the separate `canReadObject` (5641). So the
field gate does not refuse a readable field on an unreadable related
object.
- `objectql-strategy.ts` is byte-identical at `95555e71`, `83480c6a` and
`5f6b63a6` (`git diff --quiet`, exit 0 for both ranges).
- Its `generateSql` passes a one-hop cross-object dimension through
`planCrossObject` (895 ff.). That function throws only for the
out-of-envelope shapes: a cross-object time dimension, measure or
filter, a multi-hop dimension, a non-recombinable measure. The one-hop
dimension renders a LEFT JOIN (461-466), and the statement is returned
(638).
- So on the ObjectQL strategy the echo printed a statement for a one-hop
dimension through a related object the caller may not read.

**After, on `main` at `a5bce40888`:**

- `queryObjects` (1607-1616) adds every object a named member reads.
- The admission at 1481 covers that set before `generateSql()` resolves
a strategy (2580).

### Measured after, on both strategies

The measurement was a scratch probe at `a5bce40888`, copied into
`packages/rest/src` for one run and removed by an EXIT trap. It was
never committed; `git status --porcelain` was empty afterwards.

- **Build:** the probe's dependency closure was built under
`os-verify-lock.sh` first: `turbo run build --concurrency=1` over
`@objectstack/service-analytics...`, `@objectstack/plugin-security...`,
`@objectstack/objectql...` and `@objectstack/driver-sql...`. That is 19
tasks, `VERDICT command-exit 0`.
- **Run:** `pnpm --filter @objectstack/rest exec vitest run
--maxWorkers=2` on the one file gave 1 file / 2 tests passed, `VERDICT
command-exit 0`.
- **Fixture:** the composition is the shipped one, with the real
security layer: `SecurityPlugin` over `ObjectQL` on `SqlDriver`
(SQLite), and `AnalyticsServicePlugin` over the same engine. There are
two compositions: `native` (the plugin's own capabilities) and
`objectql` (narrowed to the engine-aggregate path).
- **What was asked:** each query body first passed the door's own body
schema (`AnalyticsQueryRequestSchema`). It then went to `generateSql`,
the call `POST /api/v1/analytics/sql` makes
(`packages/runtime/src/domains/analytics.ts:141-159`). The thrown
`status` is the HTTP status
(`packages/runtime/src/dispatcher-plugin.ts:646-649`).

| Question, inferred cube | Member caller, `native` | Member caller,
`objectql` | System caller, either |
|:---|:---|:---|:---|
| A field the member may not read, grouped | `403 PERMISSION_DENIED` |
`403 PERMISSION_DENIED` | statement printed, by the composition's
strategy |
| The same field, filtered | `403 PERMISSION_DENIED` | `403
PERMISSION_DENIED` | statement printed |
| A one-hop dimension through a related object the member may not read,
path not declared | `403 PERMISSION_DENIED` | `403 PERMISSION_DENIED` |
statement printed |
| Control: a readable field | statement printed (`NativeSQLStrategy`) |
statement printed (`ObjectQLStrategy`) | statement printed |
| Control: a readable related object, path not declared | statement
printed (`NativeSQLStrategy`) | statement printed (`ObjectQLStrategy`) |
statement printed |

Before and after both hold for both banners. So each banner is scoped
the way PR objectstack-ai#20991 scoped objectstack-ai#20935's. The note's own pins in
`packages/rest/src/analytics-field-permission-gate.test.ts` and
`analytics-relationship-path-admission.test.ts` assert the same echo
refusals on both compositions. The probe adds the system-caller and
readable controls, and it ran the bodies through the door's schema.

### The objectstack-ai#20887 **Why** paragraph

The dev measured on the branch point of PR objectstack-ai#20916, `00a92e18da`. That is
the parent of its first commit, `5687276296`.

**The base predates both gates.** `git merge-base --is-ancestor
00a92e1 X` exits 0 for X = `95555e71`, `1571aedc`, `83480c6a` and
`5f6b63a6`, so both landings descend from it. The reverse, `1571aedc`
against `00a92e18da`, exits 1. Its control leg, `00a92e18da~200` against
`00a92e18da`, exits 0, and the repository is not shallow.

**Clause one, "answered rows for a condition on a field the caller
cannot read", held on that base:**

- No non-test source in the package names `getReadableFields` (`git
grep` exit 1; control: six hits in `analytics-service.ts` on `main`).
- `callCtx` (`analytics-service.ts:1283` ff. at `00a92e18da`) runs only
the object admission (1308) and the read-scope pre-pass.
- The base filter normalizer flattened the form to a dotted member
(`strategies/filter-normalizer.ts:1296-1299`). The native strategy
joined the declared include.
- The dev's first report (`5916988260` on objectstack-ai#20887) records the measured
rows.

**Clause two, "without the declared join it named a table that does not
exist (500)", held on that base:**

- `strategies/native-sql-strategy.ts:771-783` falls back to the
relationship name as the joined table when no join is declared.
- The join allowlist applies to a compiled dataset only. An inferred
cube has none (`analytics-service.ts:1266-1268`;
`native-sql-strategy.ts:575-576`).
- The object admission covered the base object and the declared joins
only (`analytics-service.ts:1404`, `1416` ff.). So nothing refused
before the statement ran.

The paragraph is anchored to that base in the delta review's own words;
the clauses are kept.

### The ADR-0087 gate's reading

`breakingDeclaration` and `readDisposition` were re-run on each note at
`a5bce40888` and at this head. All three notes read the same on both:

- `breaking: true`;
- the signals `BREAKING`, `bang` and `clause-②-narrowing`;
- the disposition `not-required (no-migration-prescription)`.

The gate itself counts the three as inherited, not introduced: it skips
a changeset already breaking at the branch point.

## Changeset gate: no `skip-changeset`, and `Check Changeset` stays red

This PR edits three pending changesets and adds none, so `Check
Changeset` goes red by design. `check-empty-changeset.mjs --base
origin/main` exits 1 and refuses all three files as the DELIBERATE
CORRECTION class. Ruling D on objectstack-ai#18375 says `skip-changeset` is never
applied to a PR that edits an existing changeset, so no label is
applied. `Check Changeset` is not a required context.

**Confirmation:** this lane's at-tier contract review record on this
PR's head judges each rewritten sentence above. The sentences are:

1. the objectstack-ai#20917 note's banner, now scoped to include `POST
/api/v1/analytics/sql` on either strategy;
2. the objectstack-ai#20933 note's banner, scoped the same way;
3. the first sentence of the objectstack-ai#20887 note's **Why**, now anchored to the
base before the field-level gate and the relationship-path admission
landed.

`Clause-②: no` was checked against `scripts/pm/clause2-line.mjs`. A
wording edit to pending notes widens no accept set and adds no public
surface, so the value is `no`. With no arm, the line declares no
direction.

## Verification at `56fc0e77e3`

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (exit 0) derived 19 commands from the three
changed paths. Each ran on this head, with its exit code captured before
any pipe.

- **18 exit 0:**
- `check-adr-0087-registration.mjs --base origin/main` and `--self-test`
  - `check-changeset-no-major.mjs --base origin/main` and `--self-test`
  - `check-closing-keyword-parity.mjs` and `--self-test`
  - `check-comment-mask-corpus.mjs`
  - `check-empty-changeset.mjs --self-test`
  - `pm/release-rehearsal-clone.mjs --self-test`
- `check:changeset-gate-self-tests`, `check:driver-memory-census`,
`check:gitlink-declared`, `check:nul-bytes`, `check:objectui-changeset`,
`check:pm-changeset-deadline-census`, `check:published-files`,
`check:refd-timer-probe`, `check:watch-hint-literal`
- **1 exit 1, by design:** `check-empty-changeset.mjs --base
origin/main`, the DELIBERATE CORRECTION class above.
- **Roster family:** `check-changeset-fixed.mjs` (its roster lives under
`.changeset/`) exited 0.
- **Reconciliation:** `dispatch-gates.mjs --ran` with the recorded exit
codes exited 0. It read "19 derived, 19 run, 0 NOT-MEASURED, 0 UNRUN".
- **Main moved:** `origin/main` gained `f8178ffece` after the branch
point. It touches none of the three notes, and all three are still
present there.
- **NOT MEASURED, by design:** the type-check lanes and the package test
suites. The diff touches no TypeScript and no package source.

## Acceptance notes

- **Wording noted, not changed (corrections only, no new claims).**
- The objectstack-ai#20917 note's **What changed** ends: "the ObjectQL strategy and
the data API already refused them". Its ADR-0087 marker says the engine
"already refuses" such queries "on the ObjectQL strategy". Both hold for
the query and dataset doors, not for the SQL echo, which printed on that
strategy (reading above). The corrected banner carries the echo's scope.
- The objectstack-ai#20933 note's **Refusals that change form** says: "On the ObjectQL
strategy a related object the caller may not read was already refused".
That holds for the query door. On the echo, a one-hop dimension through
such an object printed (reading above). The corrected banner carries the
echo's scope.
- **Where the probe measured.** It measured the service call the door
relays, not the mounted HTTP route: identity resolution needs an auth
composition the probe did not boot. The relay and the status mapping are
cited at file:line above.
- **Not in this PR:** objectstack-ai#20935's note. PR objectstack-ai#20991 holds it.
- **Disclosure discipline holds.** No request recipe, field spelling or
returned value appears here.

## Patch rounds (the seat's append from the dev's report on objectstack-ai#20917; the
dev writes a body only once)

### Patch round 1

At `db64c37690`, on the seat's note `5923016038` on objectstack-ai#20917, which takes
the dev's class (a) finding into this PR. Two sentences beside the
corrected banners are qualified to the doors they hold for. Nothing else
changes: each is still one sentence, rewrapped in place, and both
ADR-0087 disposition markers are byte-identical.

| Note | Before | After |
|:---|:---|:---|
| `.changeset/20917-analytics-field-permission-gate.md`, **What
changed**, last sentence | "The native-SQL strategy, the one a SQL
driver serves first, answered such queries; the ObjectQL strategy and
the data API already refused them." | "The native-SQL strategy, the one
a SQL driver serves first, answered such queries; the ObjectQL strategy
already refused them on `POST /api/v1/analytics/query` and `POST
/api/v1/analytics/dataset/query`, as the data API did, but printed the
statement on `POST /api/v1/analytics/sql`." |
| `.changeset/20933-analytics-relationship-path-admission.md`,
**Refusals that change form**, first sentence | "On the ObjectQL
strategy a related object the caller may not read was already refused;
it now answers the analytics door's refusal rather than the engine's,
the same one a declared join gets." | "On the ObjectQL strategy a
related object the caller may not read was already refused on `POST
/api/v1/analytics/query` and `POST /api/v1/analytics/dataset/query`,
though `POST /api/v1/analytics/sql` printed the statement; on those two
doors it now answers the analytics door's refusal rather than the
engine's, the same one a declared join gets." |

**Readings:**

- **objectstack-ai#20917's sentence, at `95555e71`.** On the query door, the ObjectQL
strategy's `execute()` reaches the engine (`objectql-strategy.ts:301`)
before it renders its own echo (350-354). The dataset door runs the same
`execute()`, so both refused through the engine. The SQL echo's
`generateSql` (372-638) renders with no engine call, and `callCtx` had
no field gate (`analytics-service.ts:1283-1327`). So `POST
/api/v1/analytics/sql` printed the statement.
- **objectstack-ai#20933's sentence, at `83480c6a`.** On the query and dataset doors,
a one-hop dimension through a related object goes through
`executeCrossObject`. Its `resolveFkAttr` (1176 ff.) reads that object
through the engine as the caller (1211-1215). The echo renders the join
(461-466) and returns the statement (638), with no engine call, and the
object was outside the admitted set (`analytics-service.ts:1584-1608`).
After the change, on `main`, the echo answers `403 PERMISSION_DENIED` on
both strategies (round 0's probe).

**ADR-0087 reading at `db64c37690`:** for both notes,
`breakingDeclaration` reads `breaking: true` with the signals
`BREAKING`, `bang` and `clause-②-narrowing`, and `readDisposition` reads
`not-required (no-migration-prescription)`. Both are unchanged from
`a5bce40888`, and the objectstack-ai#20887 note reads the same.

**Gates at `db64c37690`:**
- `dispatch-gates.mjs --commands` derived the same 19 commands. 18
exited 0. `check-empty-changeset.mjs --base origin/main` exited 1 by
design: the DELIBERATE CORRECTION class, with all three notes named.
- The roster gate `check-changeset-fixed.mjs` exited 0.
- `--ran`: 19 derived, 19 run, 0 not measured.
- The derivation was repeated at `origin/main` `8055ff2279`, which had
changed one roster file, and gave the same 19 commands. None of the
three notes changed on `main` over that range.

**Acceptance note, wording kept:** both ADR-0087 disposition markers are
HTML comments, and they stay byte-identical. The objectstack-ai#20917 marker still
says the engine "already refuses" such queries "on the ObjectQL
strategy". Like the two sentences above, that wording holds for the
query and dataset doors, not for the SQL echo.

---
_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/xl tests tooling

Projects

None yet

2 participants