Skip to content

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

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

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

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #20917
Clause-②: yes (narrowing)

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

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

Both directions measured:

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

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

Per position

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

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

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

The reader, and how the gate reaches it

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

What is judged, and what is not

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

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

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

Ablation, predicted before running, at 40c4979d8

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

Verification, at 992a592db

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

Docs

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

Acceptance notes


Generated by Claude Code

…cs door (#20917)

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

Committed ahead of the change that satisfies them.

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

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

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

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

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

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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

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

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

Which tree this was computed on

This run read content/docs from 324831b54be9dce95f9535222950dab3c2b8599a — the merge of head 992a592dbbeaf200e16fd48051546f1d981b6295 into base aaad682dbcb36bd00635a40530aca826062b9903, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 324831b54be9dce95f9535222950dab3c2b8599a && git checkout 324831b54be9dce95f9535222950dab3c2b8599a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aaad682dbcb36bd00635a40530aca826062b9903 992a592dbbeaf200e16fd48051546f1d981b6295 && git checkout -B drift-repro aaad682dbcb36bd00635a40530aca826062b9903 && git merge --no-ff 992a592dbbeaf200e16fd48051546f1d981b6295

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 992a592dbbeaf200e16fd48051546f1d981b6295
Local-runs: none

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

① Derived judgments

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

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

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

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

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

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

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

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

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

② Semver level

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

③ Boundary flags

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

VERDICT: PASS

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

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
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