Skip to content

fix(plugin-security)!: a field the caller may not read is judged as a cross-field comparand exactly as it is as a filter key (#20932) - #20954

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20932-predicate-guard-comparand
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20932-predicate-guard-comparand

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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 #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 security(analytics): the native-SQL strategy answers a query naming a field the caller has no field-level read permission for, where the engine and the ObjectQL strategy refuse 403 #20917's (PR fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) #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

A field the caller may not read is named by a cross-field comparand as
surely as by a condition key: the comparison reads its value. These pins
hold every comparand position the filter grammar admits to the refusal
the same hidden field gets as a key (403 PERMISSION_DENIED, same body),
on the data read and on the aggregate path, with a readable comparand
as the control:

- predicate-guard.test.ts: the collection and the verdict, per position.
- rest data-field-comparand-permission.test.ts: the real SecurityPlugin
  on a real ObjectQL over a real SqlDriver, through the data query route.

Red on this commit by design; the fix follows.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…ield rule

The field predicate guard collected the fields a condition names from
its keys only, so a hidden field named only as a cross-field comparand
was never judged by the field rule. It is now collected into the same
set by collectConditionFields and judged by the same rule: a hidden
field as a comparand answers the same 403 PERMISSION_DENIED, in the
same words, as the same field written as a key, on every verb that
carries a predicate (the data read and the aggregate path included).

What a comparand reference is stays the filter grammar's answer: each
node of a field constraint is asked of the spec's exported
FieldReferenceSchema. The walk carries no operator or position list,
and there is no second guard.

Clause-②: no (narrowing)

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…d changeset

The changeset states the behaviour and the verbs it covers; which routes
reach those verbs is the class ("every route that reaches them"), so the
prose carries no route path.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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

Coarse fallback — 16 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 013f97df93ed66d0aeec45ec1d507da303991260 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4c872c3218cf3c889e77273f9b8ab553329c1245 — the merge of head 215bc699b144e8436b8908d01f4a0f40dc51c306 into base 013f97df93ed66d0aeec45ec1d507da303991260, 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 4c872c3218cf3c889e77273f9b8ab553329c1245 && git checkout 4c872c3218cf3c889e77273f9b8ab553329c1245
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 013f97df93ed66d0aeec45ec1d507da303991260 215bc699b144e8436b8908d01f4a0f40dc51c306 && git checkout -B drift-repro 013f97df93ed66d0aeec45ec1d507da303991260 && git merge --no-ff 215bc699b144e8436b8908d01f4a0f40dc51c306

node scripts/docs-audit/affected-docs.mjs --json 013f97df93ed66d0aeec45ec1d507da303991260

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 215bc699b144e8436b8908d01f4a0f40dc51c306
Local-runs: none

Read at 2026-09-30T22:59Z: card #20932 (body and its 4 comments: triage 5919556782, claim 5919655256, dev report 5921030474, seat ACCEPT 5921051412 — the last is context, not an input to this verdict), PR #20954 (body, 4-file list, 1 comment, net diff against main from merge base 1571aedce), and the check-runs on the head. Disclosure discipline (security family) holds in this record: classes and positions only, the card's own vocabulary, no request shape, no field spelling from the pins, no returned value beyond the code the card names.

Check-runs on the head, latest run per name, read 2026-09-30T22:54Z: 34 names — 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke opt-in), 0 failure, 0 in progress. Check Changeset (which runs the ADR-0087 registration gate and the level-axis gate against the merge base) and Lint & Repo Gates are among the 31. Nothing is pending, so this verdict is unconditional.

① Derived judgments

Read off the diff against the pre-fix source, the guard's one call site (security-plugin.ts, step 2.9 of the engine middleware) and the filter grammar at the head (FieldReferenceSchema, packages/spec/src/data/filter.zod.ts).

  1. Accept-set NARROWING at the field predicate guard — right. collectConditionFields now also walks each non-logical key's value and adds the first segment of every node that FieldReferenceSchema.safeParse accepts, into the one set the existing rule judges. A query whose field constraint carries a cross-field comparand naming a field the caller may not read is refused with the key form's refusal (same constructor, same reason, same offending-field detail), where before it was served. This is triage's direction 5919556782 verbatim: one collection, one rule, no second guard, no operator or position list. The set only grows, so nothing refused before is served now.
  2. Reach of the collection — right, and derived from the grammar, not a list. collectQueryFields calls the collector on where, having and each aggregation's filter, so all three clauses inherit it. The grammar at the head admits a reference as the whole comparand of the six scalar comparisons and in the whole-day offset wrapper (the wrapper's own nested reference is a { $field }-only shape, which FieldReferenceSchema also accepts, so the offset column is collected too — pinned); it refuses a reference as a list member or a range endpoint. In those two refused positions a reference naming a hidden field now meets the guard's 403 before the grammar's 400 — the same order the key form already has — and the changeset states it. Right.
  3. Every predicate-carrying verb, one call site — right. assertReadableQueryFields is called once, at step 2.9, on the caller's AST for the read, findOne, count and aggregate, and on the caller's own where for bulk update / delete; the collector walks that where too. The aggregate path is therefore not a separate caller: grouped bodies sent through the data query route reach the engine's aggregate verb and the same middleware. The step runs BEFORE RLS injection (step 3), so a row-level policy that compares against a hidden field is untouched — which the changeset also says. The guard's permission map already folds the ADR-0090 D10 delegator intersection and the spec/security: field masking is all-or-nothing — no partial masking (phone last-4, ID middle-8), and maskingRule was pruned as dead in 2026-06 #8993 partial-mask fold, so a comparand naming a masked-for-this-caller field is refused by the same fold, with no second list. Right.
  4. Dotted comparand gated on its first segment — right; identical to the key rule, so a relation traversal in a comparand is judged by the local relation field's permission exactly as a dotted key is.
  5. Public surface — no widening, right. No export added or removed; collectConditionFields, collectQueryFields and assertReadableQueryFields keep their signatures and return a superset only for inputs that carry a reference; the new helper is module-private. @objectstack/spec was already a dependency of plugin-security and ./data is an existing subpath export, so the new import moves no manifest. packages/spec/src, packages/objectql/src, service-analytics and every governed surface are untouched; the four files are all inside the claim's file surface; head repo is the base repo.
  6. The nested-relation corner — right as a security fix, wording corrected (carried to ③). Inside a nested-relation condition the walk collects a comparand reference and judges it against the QUERIED object's permission map, while a nested KEY there is not collected (pre-existing rule: gate on the relation field). So the two forms are not symmetric in that corner; the asymmetry is fail-closed — at worst an over-refusal when a related-object field name coincides with a hidden or masked local field — and it can never serve a local hidden field. The related object's own hidden fields are judged by neither form; that is pre-existing and outside this card's class.
  7. Refusal-equality pins — right. The route pins read the key form's refusal per hidden field and per path FIRST and hold the comparand form to the same status and whole body; the unit pins hold code, status, message and details equal; a readable comparand is the control in every position on both paths. The red/green (unit 23 red / route 15 red on the pins commit, all green at the head) and the ablation (collector call made a no-op, predicted = observed, restore proven) are the dev's readings on the head; the check-runs on the head (Test Core 1–6, type-check lanes) are the gate verdicts, and they are green.
  8. Disclosure — right. The card, the PR body, the changeset text (which ships in CHANGELOG.md) and the three commit messages state the gap by class and position; the only request shapes are inside the two pin files, which the claim's file surface names. Commit trailers are the model-free pair.

② Semver level

  • Changeset .changeset/20932-predicate-guard-comparand.md: @objectstack/plugin-security: minor, the BREAKING banner, a migration paragraph, and exactly one ADR-0087 marker: not-required (no-migration-prescription).
  • Level — right. A no (narrowing) is BREAKING (AGENTS.md Post-Task §3); during the launch window a breaking change ships as minor and never major (check-changeset-no-major). patch would be wrong (the change is not a fix of what the guard already refused; it narrows what is served), and skip-changeset would be wrong (a released package's runtime behaviour moves). The PR title's ! marker agrees.
  • ADR-0087 disposition — right. No authorable key, spelling, export or stored shape moves, so objectstack migrate meta has nothing to rewrite; the body's "grant the field's read permission, or compare against a readable field" is operational guidance to an administrator, not a code-rewrite prescription, so the category's one mechanical refusal does not bite. The gate that reads this (Check Changeset) concluded success on the head.
  • No changeset for @objectstack/rest — right: the diff adds a test file there and nothing that publishes.
  • Clause-②: line: no (narrowing) — right on both halves. no: no new export and no new accepted input, so nothing widens. (narrowing): measured present — every route refusal pin answered served on the pre-fix source and refused at the head. The line is spelled identically in the PR body (line 2), the fix commit and the changeset.

③ Boundary flags

open_questions in the dev report: none — nothing to answer.

Dev flags (PR Acceptance notes, out_of_scope_findings, deviations), each answered:

  1. A comparand inside a nested-relation condition (out_of_scope_findings[0], carrier none, NOT MEASURED) — answered with a correction. The dev's "can only over-refuse, never serve a hidden field" holds for the queried object's fields; the precise shape is ① item 6: comparand collected, nested key not, judged against the local permission map, fail-closed. Acceptable for a p0 security fix in this direction. Disposition per the finding rule (no carrier ⇒ Acceptance notes) stands; if the owning seat later measures a reach for the over-refusal (a related-object field name coinciding with a hidden local field, under a grammar that resolves the reference on the related row), it is a class-c card of its own — not a hold on this PR.
  2. having pinned at the guard only, not through a route — answered: the guard walks having through the same collectQueryFields call as where; whether a route carries having is [P2] data: QueryAST declares 12 members no executor runs — the liveness ledger governs metadata types, not the request surface #4286's open enforce-or-remove question, not this card's. Accepted.
  3. findOne, count, bulk update / delete not pinned per verb — answered by construction, verified at the one call site (① item 3). Accepted.
  4. Analytics not touched, not pinned — answered: outside the claim by the order's exclusion; it inherits the collection through the engine path; its own gate is security(analytics): the native-SQL strategy answers a query naming a field the caller has no field-level read permission for, where the engine and the ObjectQL strategy refuse 403 #20917's. Accepted.
  5. Drivers (route pin on SQLite only) — answered: the guard runs in engine middleware before any driver is reached. Accepted.
  6. main moved 8 commits past the base, branch not merged — answered: no commit touches the claim's surface; CI ran on the merge ref and is green. Accepted.
  7. CI not awaited by the dev — answered here: every check on the head has concluded (see the roster above); nothing pending.
  8. Four runs, three restarts; PR opened and assigned by the previous run; measurements re-used with recorded exit codes; ablation taken on the fix commit whose diff to the head is one changeset line — answered: the re-used readings are each tied to the head sha, and the ablation's tree differs from the head only in a file no test reads. No second write was spent. Accepted.
  9. No empty-branch probe push — answered: the order's disclosure clause forbids a push before the fix is committed on the pins; the first push carried both. Accepted.
  10. Docs drift check: nothing to list — answered: the guard's documented rule ("filtering, sorting, grouping or aggregating by a hidden field is refused") is strengthened, not contradicted; no hand-written page names the collector. No flag.

Escalations: none. Landing note for the owning seat: no governed path is touched, so this record is not a Tier S carrier; the ordinary landing rule applies (the PR is still a draft).

Implemented-by: claude/issue-20932-predicate-guard-comparand
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 23:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit de8cd58 Sep 30, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20932-predicate-guard-comparand branch September 30, 2026 23:24
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
… 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>
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/m tests tooling

Projects

None yet

2 participants