Skip to content

fix(security,service-analytics)!: the security contract publishes which fields a caller may query on, and the analytics field gate refuses a masked field as a group or filter member (#20935) - #20955

Merged
os-justin merged 7 commits into
mainfrom
claude/issue-20935-masked-not-queryable
Sep 30, 2026
Merged

os-justin merged 7 commits into
mainfrom
claude/issue-20935-masked-not-queryable

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Closes #20935
Clause-②: yes (narrowing)

A field whose maskingRule applies to the caller is SERVED, with its value replaced, so the published read projection ISecurityService.getReadableFields counts it readable. It is not queryable: the engine refuses it as a group key, an aggregate input, a filter or a sort key, because each of those gives back what the mask hides. The analytics field gate from #20917 was exactly as wide as the read projection. So a masked-for-this-caller field could be grouped or filtered by on the native-SQL strategy, which compiles its own statement. The engine and the ObjectQL strategy refuse the same query.

This PR publishes the missing answer on the security contract, implements it from the one decision the engine already makes, and has the analytics gate ask it. The masking rule itself is unchanged, and the analytics layer does not re-derive it.

What changed, per surface

  • Contract (packages/spec/src/contracts/security-service.ts). ISecurityService gains an optional getQueryableFields(object, context). It answers the fields the caller may filter, sort, group or aggregate by: the exact complement of what the engine's field guards refuse. It is a subset of getReadableFields, and the two differ by exactly the fields the caller is served masked. It has the same two empty answers as its siblings: undefined is no answer, [] is none. A system context gets every field.
    • Its absence is not soft. A consumer that cannot get this answer (the method is missing, or it answered undefined) must treat every field that declares a maskingRule as not queryable, whoever the caller is. Falling back to the read projection alone would admit exactly the masked fields.
  • Implementation (packages/plugins/plugin-security/src/security-plugin.ts).
    • The engine's predicate guard (step 2.9) and aggregate-input guard (step 2.5b) each built their "not queryable" map inline. They now call one private derivation, computeQueryGuardFieldPerms: the evaluator map, the requiredPermissions fold, the on-behalf-of delegator intersection, then every masked-for-this-caller field folded in as non-queryable.
    • getQueryableFields reads that same map, so a field is in the answer if and only if a query naming it passes both guards. The guards refuse exactly what they refused before.
    • It is registered on the security service beside getReadableFields.
  • Gate (packages/services/service-analytics/src/field-read-admission.ts + minimal wiring in analytics-service.ts and plugin.ts).
    • AnalyticsServiceConfig gains getQueryableFields. The door's one field gate asks it beside getReadableFields for the same objects. A member is admitted only when both answers carry its field. There is no second gate and no per-strategy copy.
    • A refused member answers 403 PERMISSION_DENIED in the engine's own words for that field. Those are the words the engine answers a masked field with.
    • A queryable reader that throws refuses the query, fail-closed and logged at error, the same way the read reader does.
    • AnalyticsServicePlugin bridges the hook to the security service with the same absent / unusable / usable resolutions as the read half, plus one more state. When a usable service predates the method, or answers undefined, the bridge fails closed: the read projection less every field whose declaration carries a maskingRule, for every caller. That over-refuses a caller for whom the rule is lifted, which is the safe direction. It is also the only one available, because deciding for whom a rule is lifted would be a second copy of the masking rule.
    • A host that constructs AnalyticsService itself with getReadableFields and without getQueryableFields is warned once at construction.

Why the PR line reads yes (widening), and the analytics changeset declares a narrowing

The line above is the claim's, as corrected under the seat's review adoption 5921385686 (scripts/pm/clause2-line.mjs:93: a diff that widens one surface and narrows another is spelled yes (narrowing)). The diff does widen the public surface: one optional contract member, its implementation on the service, and one optional config hook. It also narrows one accept set. Analytics queries that grouped, aggregated, filtered or sorted by a field the caller sees masked were answered on the native-SQL strategy (and printed by the SQL echo on both strategies), and they are now refused. That narrowing is declared where the level and ADR-0087 gates read it: in .changeset/20935-analytics-masked-field-not-queryable.md, which carries the **BREAKING** banner, its own Clause-②: yes (narrowing) line and an ADR-0087 not-required (no-migration-prescription) disposition, as #20917's changeset did. @objectstack/spec and @objectstack/plugin-security ship minor (widening). Every bumped package is minor, which is what yes requires. check-changeset-no-major and check-adr-0087-registration both exit 0.

Pins

  • packages/rest/src/analytics-masked-field-gate.test.ts covers both compositions over the real SecurityPlugin, ObjectQL and SqlDriver (native-SQL first, and ObjectQL only), with synthetic fixtures.
    • Two callers read the same fields: a member for whom each field's masking rule applies, and an unmasker who holds the capability that lifts it.
    • For the member, a masked field as a group member and as a filter member answers the engine's live refusal for the same field and caller (code, status and message equal). This holds on the base object, through an authored alias and through a joined object, on the cube read, the SQL echo and the dataset door. Neither the engine's aggregate nor its raw statement runs.
    • Controls: the unmasker is answered exactly as the system caller is, and the member is answered for fields it may query on.
  • packages/plugins/plugin-security/src/get-queryable-fields.test.ts is an equivalence. For every field and four positions (filter, sort key, group key, aggregate input), "the real middleware admitted it" equals "the field is in getQueryableFields". The cases cover a member, the capability holder, an agent alone and the same agent on behalf of a delegator. It also pins the contract's answers: masked means readable and not queryable, the system and no-sets answers, the unresolvable object, the dangling delegator, and registration on the service.
  • packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts runs the masked positions its cases name (six) through both strategy paths with nothing executed. It also covers the throwing reader, each reader judged on its own, the construction warning, and the bridge: service answer, rule lifted, the fail-closed fallback for a service that predates the method and for an undefined answer, and no security service.
  • packages/spec/src/contracts/security-service.test.ts pins the member's optionality (an unguarded call does not compile), the masked-readable-not-queryable relation and the two empty answers.

Ablations, predicted before running, at 20c56c9810

Each ablation followed the same legs:

  1. Mutate through scripts/ablation-replace.mjs: the anchor hits once and the blob moves.
  2. Where the suite reads dist/, rebuild the package and confirm with ablation-dist-preflight that the marker is in 2 built files.
  3. Run the suites.
  4. Restore, and prove the blob equals HEAD with an empty git diff HEAD.
  5. Rebuild, and confirm with the --absent preflight that the marker is gone from all 6 built files and the tree is clean.

Commits after 20c56c9810 touched only the bridge's fallback. It no longer exempts a system caller, which kept a new elevation read site out of the system-context census. None of the three ablations reaches that branch.

Verification, at fc844c433e (main merged at 95fed33a20)

  • @objectstack/service-analytics: test 147 files, 3398 passed, and typecheck 0 (the new test file is in the tsc program: it reported a tuple error before its fix). This run was taken at e84882a09f. The later edits were re-run: the two gate unit files, 72 passed, plus typecheck 0.
  • @objectstack/plugin-security: test 150 files, 3237 passed and 23 skipped, and typecheck 0 including the test layer.
  • @objectstack/spec: typecheck 0, src/contracts 45 files and 434 passed, and check:generated shows all 15 artifacts up to date.
  • @objectstack/rest: every analytics-* route file (13 files, 175 passed and 3 skipped), and typecheck 0 including the test layer. Both field-gate route files re-ran at 03153e00f1 after the second merge of main: 22 passed.
  • dispatch-gates --commands derived 87 commands at fc844c433e, with no stale tree.

Acceptance notes


Generated by Claude Code

…e the analytics field gate ask it

The security contract gains an optional getQueryableFields: the fields a
caller may filter, sort, group or aggregate by. plugin-security derives it
from the one query-guard map its predicate guard and aggregate-input guard
now share, so a field served masked is readable and not queryable. The
analytics field gate asks it beside the read projection; the plugin bridge
fails closed for masking-rule fields when the security service cannot answer.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…ytics strategies

A door-level pin over the real SecurityPlugin, ObjectQL and SqlDriver: a
field the caller is served masked, as a group or a filter member, answers the
engine's own refusal on both strategies before any strategy runs, and a caller
the masking rule is lifted for is the control. plugin-security pins that
getQueryableFields equals the middleware's two query guards field for field;
the analytics unit pins cover the gate, the bridge's fail-closed fallback and
the construction-time warning.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…ation and the analytics narrowing

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…ty, so it refuses masked-rule fields for every caller

The fallback for a security service that cannot answer getQueryableFields
exempted a system context by reading its system bit, a new elevation read
site. It cannot say for whom a masking rule is lifted, so it now refuses
every caller alike; the contract text and both changesets say so.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added 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 3 package(s): @objectstack/plugin-security, @objectstack/service-analytics, @objectstack/spec, touching 16 documentable anchor(s).

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

  • content/docs/kernel/contracts/index.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/kernel/runtime-services/index.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/system-context.mdx (via resolveProjectionFieldMask (symbol, a method of class SecurityPlugin))
  • content/docs/plugins/packages.mdx (via AnalyticsServicePlugin (symbol, a top-level class), SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

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

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v17/17-5.mdx (via AnalyticsService (symbol, a top-level class), ISecurityService (symbol, a top-level interface))

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

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

Coarse fallback — 140 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 bd3224fe607a26553bc16e6f45084848a96234e4 — the merge of head fc844c433ed2e8e175e4568db4e03a4e58b0c5c9 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 bd3224fe607a26553bc16e6f45084848a96234e4 && git checkout bd3224fe607a26553bc16e6f45084848a96234e4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 013f97df93ed66d0aeec45ec1d507da303991260 fc844c433ed2e8e175e4568db4e03a4e58b0c5c9 && git checkout -B drift-repro 013f97df93ed66d0aeec45ec1d507da303991260 && git merge --no-ff fc844c433ed2e8e175e4568db4e03a4e58b0c5c9

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.

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 013f97df93ed66d0aeec45ec1d507da303991260 → 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: fc844c433ed2e8e175e4568db4e03a4e58b0c5c9
Local-runs: none

PR #20955 for card #20935, read as the net diff against main (merge-base 95fed33a20, 12 files, +1243 / −47), the card and every comment on it (triage 5919588833, claim 5919731088, dev report 5921193653, ruling 5921222205), #20917 / PR #20931 (merged 1571aedce5), the #20933 triage note 5919572657, #20964, and the check-runs on this head. The card's disclosure discipline holds in this record: no request recipe, field spelling or returned value; tests are cited by name only.

① Derived judgments

Accept-set and public-surface changes the diff implies

  1. ISecurityService.getQueryableFields?(object, context) (packages/spec/src/contracts/security-service.ts) — a new OPTIONAL member on the published contract: a public-surface widening, and only that. Implementers keep compiling; the unguarded call does not (security-service.test.ts, the [#20935] getQueryableFields is OPTIONAL case uses a ts-expect-error pin). Right.
  2. The registered security service gains getQueryableFields beside getReadableFields (security-plugin.ts, the registration block and the registration log line). A runtime-surface widening; pinned by get-queryable-fields.test.ts is exposed on the registered "security" service. Right.
  3. AnalyticsServiceConfig.getQueryableFields? — a new optional config hook, bridged by AnalyticsServicePlugin. Widening. Right.
  4. The analytics accept set NARROWS: a member naming a field the caller is served masked is refused 403 PERMISSION_DENIED on the cube read, the SQL echo and the dataset door, on both strategies, before a strategy is chosen. This is the card's Direction and triage's floor. Right.
  5. The engine's two guards refuse exactly what they refused before. Read old against new: step 2.5b built evaluator map → requiredPermissions fold → delegator intersection, then a separate partial-mask map, and refused a referenced field when either map marked it; step 2.9 built the same three steps and folded every applicable mask rule in as non-readable. computeQueryGuardFieldPerms is step 2.9's sequence verbatim (same evaluator call, same fold, same intersection, same computePartialMaskRules fold with the same three arguments). For 2.5b the new predicate (readable === false on the folded map) equals the old disjunction because the fold sets exactly the masked keys to readable: false, and the new run condition (folded map non-empty) equals the old (either map non-empty) because the folded map's keys are the union. Ablation C in the dev report (two pre-existing field-masking-rule.test.ts pins go red when the fold is removed) is consistent with this reading. Right — no accept-set change in the engine.
  6. ONE derivation. computeQueryGuardFieldPerms is now the only site in plugin-security that folds masking into a query verdict; both guards and getQueryableFields (through resolveProjectionFieldMask.readQueryGuardPerms) read it, and get-queryable-fields.test.ts pins the equivalence against the REAL middleware for four positions (filter, sort key, group key, aggregate input) across a member, a capability holder, an agent alone and the agent on behalf of a delegator. In service-analytics, field-read-admission.ts compares names to two answers and derives nothing; plugin.ts's fallback reads the maskingRule DECLARATION off the data engine's schema and decides for nobody whom a rule is lifted for. There is no second masking rule anywhere in the diff. Right.
  7. "A subset of getReadableFields, differing by exactly the masked fields." Verified from computeReadableFields (readable = folded map not false, OR a read-side partial rule applies) against getQueryableFields (queryable = query-guard map not false): the query-guard map carries every false the folded map carries plus the applicable rules, so queryable ⊆ readable; the difference is the fields with an applicable rule, whether or not the fold already hid them; an explicit permission-set deny sits in neither. Right.
  8. Settled answers: undefined (schema unresolvable), the full set (system context; a caller with no permission sets — the middleware skips 2.5b, which requires sets, and 2.9's map is empty under the stand-in posture), [] (unresolved posture, dangling delegator — cases the middleware refuses outright). Right, each pinned in get-queryable-fields.test.ts.
  9. No route or strategy bypasses the gate. callCtx is the one thing query() and generateSql() share; POST /analytics/query and POST /analytics/sql (packages/runtime/src/domains/analytics.ts) call those two, POST /api/v1/analytics/dataset/query reaches query() through DatasetExecutor, and the draft-preview branch of queryDataset calls the same assertFieldsReadable, which is the single call site that now passes queryableFieldsProvider. GET /analytics/meta serves no values. FallbackDelegateStrategy (a delegated MemoryAnalyticsService) is selected after callCtx. Right.
  10. Every member slot. namedQueryFields collects dimensions, time dimensions, measures, where, a measure's own filter, the dataset's own filter and order; the remaining AnalyticsQuerySchema keys (limit, offset, timezone) name no field. The queryable check sits inside the one hidden predicate applied to every named field, so a masked field is refused in every slot and the role decides only the words. Right.
  11. The fail-closed fallback (plugin.ts): a usable service that lacks the method or answers undefined gets the read projection less every field declaring a maskingRule, and the bridge reads no caller property (the head commit removed the earlier system exemption; the bridge carries no isSystem read). A system or elevated caller is over-refused rather than served, and the fallback's only read is getObject on the data engine — schema declarations, not rows — so it opens no data read site. Pinned by field-query-admission-gate.test.ts fails CLOSED for a security service that predates getQueryableFields (which also refuses the system caller) and fails CLOSED the same way when getQueryableFields answers "no answer" (undefined). Right. Disclosed residual: an object the data engine does not register contributes no declarations, so under the fallback ONLY (a security service older than this member) such an object's masked fields would pass; the native strategy compiles against the data engine's registry, so the reach is narrow.
  12. The unmasked reader of the same field is served: analytics-masked-field-gate.test.ts the control: the unmasker is answered for the same members exactly as the system caller is and the control: the member is answered for the fields it may query on, on both compositions over the real SecurityPlugin, ObjectQL and SqlDriver, each refusal compared for code, status and message against the engine's live answer for the same caller. Right.

Author-shown and AI-facing text, sentence by sentence (only the ones that are false, unsourced or over-broad are listed; everything else tested true against the tree)

  • PR body, Pins: "field-query-admission-gate.test.ts runs every masked position through both strategy paths" — over-broad. Its table has six positions (group, filter, order key, authored-alias group, joined group, joined filter); it carries no measure / aggregate-input case and no time-dimension case. The behaviour holds by construction (item 10) and the aggregate-input position is pinned on the engine side in get-queryable-fields.test.ts, but the sentence over-describes that file. Never true as written; the six it runs are true on main when this lands.
  • .changeset/20935-analytics-masked-field-not-queryable.md, "What is not affected": "A system context is unaffected." — over-broad. True on main when this lands for a lockstep deployment (the shipped service answers, and answers the full set for a system context). False under the same changeset's own "New hook" paragraph: where the security service predates the member, a system caller is refused masked-rule fields too. The sentence needs "unless the security service predates the member", or the changeset should drop it.
  • Same changeset, BREAKING banner: "on a SQL deployment" — over-narrow. The SQL echo moves from printed to refused on both strategies (the PR body says so itself), so the narrowing also reaches the /analytics/sql echo on a non-SQL deployment. The "What changed" paragraph names the three doors correctly.
  • PR body, Verification and Ablations — unsourced from this record's inputs; nothing was re-run. The claimed counts are internally consistent with the code reading above, and the gate verdicts are the check-runs below.
  • Tested true: the contract docblock (the two empty answers; system bypass; "the analytics raw-SQL path is the one in the tree" — packages/spec/liveness/analytics_cube.json records that no in-repo composition registers MemoryAnalyticsService as the analytics service); the header sentence that absence is the one non-soft case; the spec changeset ("fails soft" describes the answers, the fail-closed obligation the absence — consistent); the plugin-security changeset's "refuse exactly what they refused before" (item 5); the analytics changeset's prescription (the unmask gate IS the field's requiredPermissions, all held, per FieldSchema.maskingRule's own description; a rule declared with no capability has nothing to grant, which the sentence's second half covers); "warned once at construction" (the warn sits in the constructor under read-wired-without-query; the plugin always supplies both); "in the engine's own words" (fieldReadDeniedError spells the two engine sentences and the route pin compares message equality against the live engine); the AnalyticsServiceConfig docblock; the registration log line.
  • Disclosure discipline: the PR body, the three changesets and the three new tests carry no request recipe, field spelling or returned value from the private measurement; the fixtures are synthetic and say so.

② Semver level

  • @objectstack/spec minor — right. An optional member on a published interface is a purely additive widening: at least minor under WHICH LEVEL, not major (implementers keep compiling). Its changeset carries Clause-②: yes (widening), which is true of that package.
  • @objectstack/plugin-security minor — right. The security service gains a public method.
  • @objectstack/service-analytics minor, with the **BREAKING** banner, its own Clause-②: yes (narrowing) line and the ADR-0087 not-required (no-migration-prescription) marker — right. An accept-set narrowing is breaking and ships minor in the launch window; no authorable key, export or stored shape is removed or renamed, so there is nothing for objectstack migrate meta to rewrite; this is PR fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) #20931's disposition for the same door. check-adr-0087-registration's breakingDeclaration reads three signals from that file (the ! summary, the banner, the narrowing arm), so the breaking change is declared where consumers and the gate read it.
  • The PR body line Clause-②: yes (widening). yes is right: the level axis asks that a yes PR grade at least one moved package minor or above, and all three are minor. The ARM is the weaker of the two true arms. scripts/pm/clause2-line.mjs, the one reader, defines yes (narrowing) as "a diff that widens one surface and narrows another. Both facts are true and both are read", and yes (widening) as a widening said out loud — this diff is the former shape, and PR fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) #20931 (a config-hook widening plus the same door's narrowing) declared yes (narrowing). No gate outcome moves either way: the level axis needs only yes, and the ADR-0087 gate reads the arm from the changeset. Judged under-declared, not false: the PR-level carrier drops the breaking signal the arm exists to carry ([finding] An accept-set narrowing owes a **BREAKING** banner in core but not in platform-objects — and the ADR-0087 classifier reads the banner #16421). The seat should amend the line to yes (narrowing) — a PR-body edit that moves no head; this record stands on fc844c433e either way.

③ Boundary flags

Check-runs on fc844c433e (39 runs, 35 names after dedupe by newest started_at; none running, none failed): 30 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in) — conditional jobs). Named: Check Changeset (the level axis and the ADR-0087 registration), Lint & Repo Gates, Spec property liveness, Governed Surface Queue Guard, TypeScript Type Check, Type Check · consumer gates / debt ledger / source gates / workspace, Build Core, Test Core and Test Core (1/6) to (6/6), Dogfood Regression Gate and (1/3) to (3/3), Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Check Documentation Links, Flag docs affected by code changes, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch, filter — all success.

Implemented-by: claude/issue-20935-masked-not-queryable
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-09-30T23:16Z · rendered by the seat's at-tier review subagent on this head. The seat read its served tier family from the subagent transcript before posting. ⚠️ The disclosure discipline holds.


Generated by Claude Code

@os-justin
os-justin marked this pull request as ready for review September 30, 2026 23:19
@os-justin
os-justin added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 83480c6 Sep 30, 2026
51 checks passed
@os-justin
os-justin deleted the claude/issue-20935-masked-not-queryable branch September 30, 2026 23:41
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/xl tests tooling

Projects

None yet

2 participants