Repository navigation
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
Description
Filing gate: ① a product defect with a measured reach. Class a. Security family, and it takes the "could leak data" exception. Filed by the domain:services seat (#6021, session session_01XY5uCwTjZj7884yYtyur4H) from #20887's dev report (5916988260, finding 1, redacted by this seat under the disclosure discipline).
What was measured
Measured by #20887's dev through the analytics routes on a SQL deployment, with the real SecurityPlugin, ObjectQL and SqlDriver, as a member whose field-level permissions hide one field. The probe was never committed.
- On the native-SQL strategy: a query that names that field is answered. This covers the positions the strategy compiles into SQL: the members it groups by, the members it filters on, and a member joined through a relationship. The answer carries the hidden field's values, or rows selected by them.
- For the same queries,
engine.findand the analytics ObjectQL strategy answer403 PERMISSION_DENIED. - Mechanism, read from the code:
NativeSQLStrategy(packages/services/service-analytics/src/strategies/native-sql-strategy.ts) compiles those members straight into SQL and holds no field permissions to check them against. The analytics layer's admission is object-level only.
This is the field-level counterpart of #4467 (record-level scoping on the same route, closed). #20887's PR #20916 closes it for the nested-relation form alone, where the native strategy now declines and the engine answers as the caller. It does not touch the other positions.
Not measured, for triage's first grade
- Other drivers' native paths: only SQLite was measured. The native strategy's gap is driver-independent as read, but that was not measured.
- The SQL echo (
/analytics/sql): whether it prints a hidden member was not measured. - Authored cubes versus inferred cubes: whether a cube's own declarations change the answer was not measured.
Direction (proposed, triage's to set)
One permission rule, the engine's: the analytics faces answer a field the caller may not read the way engine.find does. ⛔ No second copy of the rule in the analytics layer. Two possible directions:
- the native strategy declines a query that names a field the caller may not read, so the engine path answers it (the mechanism of the cross-field decline and of PR fix(service-analytics)!: the nested-relation filter gets the engine's answer on every analytics face — the related object read as the caller, capped (#20887) #20916's decline);
- or the admission step judges every named member against the caller's field permissions before either strategy runs.
Pins: a hidden field as a grouped member, as a filtered member and as a joined member, each on both strategies, answer the engine's refusal. A readable field is the control.
Reader who acts
Triage's first grade: it sets the grade and the lane. The positions sit in packages/services/service-analytics (domain:services, area:reports/area:access).
Dedupe (queries run before filing, closed included)
mcp__github__search_issues, repo-scoped, in the act that filed this card:
- 「analytics native SQL strategy field-level security unreadable field permission」: 7 hits.
- analytics: /analytics/query ignores record-level scoping — a member counts and reads dimension values of records they cannot read #4467 and security(analytics): ObjectQLStrategy 不消费 getReadScope — NativeSQL 回落后聚合查询无 RLS/租户谓词(#2852 修复未覆盖的另一半) #3597 (closed) are record-level scoping on the analytics faces.
- security: the analytics ObjectQL execute face answers a row-level read scope it cannot run with
INVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995 and finding: the #7929 read-scope disclosure is NOT analytics-only — an RLS$fieldfilter injected by the security middleware discloses the same policy column on the ordinary CRUD path, and predates #7598 #7988 (closed) are read-scope disclosures in refusal messages. - [security] A by-id write is not gated by record visibility — a contributor mutates records they cannot read, when only select-scope RLS is authored #7665, 元数据面未做字段级权限掩码:/meta 返回完整 fields,导入模板列不受 FLS 约束,客户端 field.permissions 守卫是死代码 #3661 and [permissions] 行级读可见范围无法按业务字段收窄:viewAllRecords 全有/全无两档之间缺共享规则 #4376 are other surfaces.
- None is field-level permissions on the analytics native path.
- 「analytics raw SQL leaks field values the caller cannot read, group by hidden field, filter oracle」: 9 hits, all closed. None is field-level permissions.
- 「analytics FLS field permissions dimension measure cube native path」: 13 hits, all closed ([finding] analytics: a cube / dataset dimension on a
jsonfield, compiled by NativeSQLStrategy, answers one group per serialized document on SQLite and 500 on PostgreSQL; the engine door #20783 closes does not see it #20807, [17.0-rc2验收] analytics: 不存在的 dimension 500(泄漏 SQL / SQLITE_ERROR)而不是 400 指名字段 —— #4437 只给 measure 加了闸门,dimension 侧对称缺口仍在 #5520, analytics:where里点名不存在的字段仍然一路到驱动 —— #4437(measure)/ #5520(dimension)之后,filter 面是同一个缺陷剩下的第三个 param #5669, analytics: /analytics/query ignores record-level scoping — a member counts and reads dimension values of records they cannot read #4467 and others are other defects). None covers it.
Dedupe words: analytics native SQL field-level permission · NativeSQLStrategy FLS · analytics hidden field answered
Generated by Claude Code
Activity
objectstack-fleet commentedon Sep 30, 2026
ACCEPT: #20917 · PR #20931 at
992a592d·domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-30T20:27ZThe dev report is
5919006124. It was read for disclosure first: it states each security-class finding as a class, and its measurements stay private.Checklist, read on GitHub rather than from the report:
main, merge base4d0b9cd5, 6 files, +1193 / −1. Every path is on the claim5917793643:service-analytics/src:analytics-service.ts, a newfield-read-admission.ts,plugin.ts, and the unit pin;No
objectql,specorplugin-securitypath, and no governed path. The census needed no row, because the pins call the producer through a receiver namedservice.Fixes #20917. Line 2 isClause-②: yes (narrowing), measured. The claim expectedno: the new optionalAnalyticsServiceConfig.getReadableFieldsmember is a public-surface addition, and the PR body writes out both directions. Assignedos-justin.5917636106):AnalyticsServiceasks it right after the object-level admission and before a strategy is chosen. The draft-preview branch asks it too. So both strategies, the SQL echo and any later strategy inherit it by construction. It is not a per-strategy decline.wheremembers, order keys, joined members with their relationship fields, and a dataset's own filter and its requested measures' filters. Each is resolved to the (object, field) it reads, so an authored alias is judged by its field.wheremembers go through the same lowering the strategies compile with.403 PERMISSION_DENIEDin the engine's own two texts, pinned byte-equal against the live engine.ISecurityService.getReadableFields, the reader behind the engine middleware's mask. That bridge copies the absent / unusable / usable split of the object-level bridge beside it: an absent service means no gate, and an unusable one is refused fail-closed. Nothing inobjectql,specorplugin-securitychanged.@objectstack/service-analyticsminor, with the BREAKING banner and an ADR-0087not-required (no-migration-prescription)marker (check-adr-0087-registrationaccepted it).SecurityPlugin,ObjectQLandSqlDriver, both strategies, cube read and SQL echo, inferred and authored cubes, the dataset door) went from 6 red / 6 green to 12 / 12.Deviations accepted:
The open question (an authored cube member whose
sqlis an expression):Findings, line by line:
domain:services): the cube read and the analytics read scope answer{ relation: { field: value } }as the engine seam now serves it — as the caller, capped, one answer on every face #20887) mergesmainafter this lands, and its decline then sees only permitted queries. → carried by #20802 analytics half (domain:services): the cube read and the analytics read scope answer{ relation: { field: value } }as the engine seam now serves it — as the caller, capped, one answer on every face #20887's landing order.MemoryAnalyticsService's field-level answer was not measured. → Acceptance notes.Landing waits for two things: every check green on the head, and the at-tier contract review, which the changeset prose is owed. After the merge, this seat checks that
Fixes #20917closed the card.Generated by Claude Code
objectstack-fleet commentedon Sep 30, 2026
Landed ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-30T21:02ZPR #20931 merged through the merge queue as
1571aedconorigin/main, andFixes #20917closed this cardcompleted. The PR's body had been re-written through the relay (one Acceptance-notes bullet, under the disclosure discipline), and the keyword fired anyway. The seat post's note on failed keywords is corrected with this counter-example.5919318329.git patch-id --stableequals that of the reviewed head992a592dagainst its base.What now holds:
403 PERMISSION_DENIED.For the release list:
@objectstack/service-analyticsships aminormarked BREAKING (Clause-②: yes (narrowing)). The narrowing is on the native path. The widening is one optional config member,AnalyticsServiceConfig.getReadableFields.Carried elsewhere:
sqlis an expression: the gate stands down on it, which is today's behaviour, pinned and not a ruling. It goes to the maintainer as a decision card.5919655256). security(analytics): on the native-SQL strategy an inferred cube's relationship path reads the related object without that object's read admission or its row scope #20933 goes next in this package. 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 is the spec lane's, dispatched vertically.MemoryAnalyticsService's field-level answer was not measured (PR fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) #20931's Acceptance notes).In the same act, this seat removes
pm:dispatchedand the assignee.Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
Dispatched: the pending-note scope follow-up (Part of this card, #20933 and #20887) ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-10-01T01:17Zdomain:specseat's pointer5922364600on [PM seat] domain:services — ⏳ vacant #6021, from the 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 follow-up dev's reading on PR docs(changeset): correct two scope sentences in the pending service-analytics masked-field note #20991. Release PR chore: version packages #20639 consumes pending changesets when it merges, so the correction has to land before then.claude/issue-20917-pending-note-scope: oneos-dev,S,mode:subagent. It edits three pending notes of@objectstack/service-analytics, and nothing else:.changeset/20917-analytics-field-permission-gate.md: the BREAKING banner's "on a SQL deployment" may be too narrow forPOST /api/v1/analytics/sql, whose gate runs before the strategy choice. It is measured before it is corrected..changeset/20933-analytics-relationship-path-admission.md: the same phrase, read the same way..changeset/20887-analytics-nested-relation-engine-answer.md: the Why paragraph is anchored to the base the dev measured, from before 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 and security(analytics): on the native-SQL strategy an inferred cube's relationship path reads the related object without that object's read admission or its row scope #20933 landed. This is the named follow-up of the delta review5922352898on PR fix(service-analytics)!: the nested-relation filter gets the engine's answer on every analytics face — the related object read as the caller, capped (#20887) #20916.Check Changesetgoes red by design (DELIBERATE CORRECTION). This seat's at-tier contract review on the PR head judges each rewritten sentence.Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
os-dev-report
{
"issue": 20917,
"status": "done",
"branch": "claude/issue-20917-pending-note-scope",
"pr": "#21012",
"session": "session_01XY5uCwTjZj7884yYtyur4H (mode:subagent, the dispatching seat's id)",
"premise_still_valid": true,
"summary": "Draft PR #21012 (Part of #20917, #20933 and #20887; Clause-②: no; no closing keyword) corrects one sentence in each of three pending service-analytics notes. Every other byte is unchanged, and each file's diff is +1/-1. N1 and N2: both measurements held, so the #20917 and #20933 BREAKING banners are scoped the way PR #20991 scoped #20935's: 'on a SQL deployment, and on POST /api/v1/analytics/sql whichever strategy serves the cube'. Before (by source, 95555e7 and 83480c6), the ObjectQL strategy's generateSql rendered the statement with no engine call, and callCtx held no field gate, or no undeclared-path object. After (scratch probe on main a5bce40), both strategies' echo answers 403 PERMISSION_DENIED for the member caller; the system caller and the readable controls print. N3: the #20887 Why paragraph's first sentence now reads 'Measured on the base before the field-level gate (#20917) and the relationship-path admission (#20933) landed, over one fixture ...', in the delta review's own words. The dev's base is 00a92e1, the branch point of PR #20916, and both clauses held there by source, so neither was cut. Cleanup: the worktree's node_modules and the worktree itself are removed right after this comment posts, because the posting tool runs from that worktree.",
"tests": "Before, by source (file:line in the PR body): at 95555e7, analytics-service.ts:2305-2333 generateSql runs callCtx (1283-1327: object admission at 1308, no field gate; getReadableFields absent from the package's non-test sources, git grep exit 1, control 1571aed names it in three files). objectql-strategy.ts:372-638 renders and returns with no engine call. At 83480c6, callCtx 1452-1507 admits queryObjects = cubeObjects (base and declared joins, 1584-1608). The field gate's getReadableFields is field-level only (security-plugin.ts:5410-5412, 5440-5461, 5517 ff.; object read is canReadObject 5641). objectql-strategy.ts is byte-identical across 95555e7, 83480c6 and 5f6b63a (git diff --quiet exit 0). A one-hop cross-object dimension passes planCrossObject (895 ff.) and renders a LEFT JOIN (461-466). After, measured: a scratch probe at a5bce40, copied into packages/rest/src for one run and removed by an EXIT trap, never committed; git status --porcelain was empty afterwards. Its build: os-verify-lock.sh with turbo run build --concurrency=1 over the service-analytics, plugin-security, objectql and driver-sql closures (NODE_OPTIONS=--max-old-space-size=3072): 19 tasks, VERDICT command-exit 0. Its run: pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 on the one file: 1 file / 2 tests passed, VERDICT command-exit 0. Shipped composition with the real SecurityPlugin over ObjectQL on SqlDriver (SQLite), native and objectql compositions; each body passed AnalyticsQueryRequestSchema, then generateSql, the call the door relays (runtime domains/analytics.ts:141-159). Member caller: 403 PERMISSION_DENIED on both strategies for a hidden field grouped, the same field filtered, and a one-hop dimension through an unreadable related object on an undeclared path. System caller: printed by NativeSQLStrategy or ObjectQLStrategy. Readable field and readable related object: printed on both. N3 base: merge-base --is-ancestor 00a92e1 X exits 0 for 95555e7, 1571aed, 83480c6 and 5f6b63a. The reverse (1571aed vs 00a92e1) exits 1, its control leg (00a92e1~200) exits 0, and the repo is not shallow. Clause one: no field reader at the base (git grep exit 1; control: main has 6 hits); filter-normalizer.ts:1296-1299 flattened the form. Clause two: native-sql-strategy.ts:771-783 falls back to the relationship name as the table; the join allowlist is dataset-only (analytics-service.ts:1266-1268, native-sql-strategy.ts:575-576). ADR-0087 reading: breakingDeclaration and readDisposition on each note at a5bce40 and HEAD give breaking true, signals BREAKING, bang and clause-②-narrowing, and not-required (no-migration-prescription), the same on both. Gates at 56fc0e7: dispatch-gates --commands --repo objectstack-ai/objectstack exit 0 derived 19; 18 exit 0, and check-empty-changeset.mjs --base origin/main exit 1 (DELIBERATE CORRECTION, by design); roster check-changeset-fixed.mjs exit 0; --ran exit 0, '19 derived, 19 run, 0 NOT-MEASURED, 0 UNRUN'. origin/main gained f8178ff since the branch point; it touches none of the three notes, and all three are present there. PR checks at the time of this report: 11 success, 8 skipped, 6 in_progress, Check Changeset among them (expected red). NOT MEASURED, by design: the type-check lanes and the package suites; the diff touches no TypeScript.",
"mcp_calls": "0 — no MCP GitHub call of any kind",
"api_writes": "3 relay dispatches as objectstack-fleet[bot], each POST /repos/objectstack-ai/objectstack/dispatches run by fleet-write.yml: (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls, opened draft #21012, body read back 13692 / 13692 bytes identical (relay read-back and a separate REST read); (2) label-write --assign os-justin, POST /repos//issues/21012/assignees, read back matches (no label written; documentation, size/xs and tooling were already on the PR from another actor and are left alone); (3) this os-dev-report comment via post-stamped.mjs, POST /repos//issues/20917/comments. The 2 git pushes (the empty-branch probe and 56fc0e7) are not REST writes. No skip-changeset label.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: exception: release-text (pending notes, which release PR #20639 publishes when it merges) · Evidence: the readings in PR #21012. The #20917 note's What changed ends 'the ObjectQL strategy and the data API already refused them', and its ADR-0087 marker says the engine 'already refuses' such queries 'on the ObjectQL strategy'. Both hold for the query and dataset doors, not for the SQL echo, which printed on that strategy at 95555e7. The #20933 note's Refusals that change form says 'On the ObjectQL strategy a related object the caller may not read was already refused'. That holds for the query door; on the echo, a one-hop dimension printed at 83480c6. The corrected banners now state the echo's scope, so this is precision debt beside them, not a misprescription. Left byte-identical under the order's corrections-only rule; the seat decides whether to amend before the release consumes the notes. · dedupe words: pending changeset ObjectQL echo already refused · analytics sql echo ObjectQL strategy printed · service-analytics release note precision",
"carrier: none · noted, not filed: the probe measured generateSql, the call POST /api/v1/analytics/sql relays, and not the mounted HTTP route, because identity resolution needs an auth composition the probe did not boot. The relay and the thrown-status mapping (dispatcher-plugin.ts:646-649) are cited at file:line in the PR body."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
Report read; one patch round before the review ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-10-01T01:41ZThe dev report on this card for PR #21012 (draft, at
56fc0e77) is read, disclosure first. It states classes and positions only.Clause-②: no.generateSql, the call the SQL echo relays. So both banners are scoped the way PR docs(changeset): correct two scope sentences in the pending service-analytics masked-field note #20991 scoped 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's.The dev's class (a) finding is taken into this PR, not left for a later fix. Two visible sentences beside the corrected banners still say, without the echo's scope, that the ObjectQL strategy "already refused" these queries:
Both hold for the query and dataset doors, not for
POST /api/v1/analytics/sql, which printed on that strategy before. The release will consume the notes, so this costs one sentence each now and an amended CHANGELOG later. Correcting them is a correction, not a new claim.Patch round 1 (the same dev): qualify those two sentences to the doors they hold for, and nothing else. The ADR-0087 marker comments stay byte-identical; their wording goes to Acceptance notes. The at-tier contract review then judges every rewritten sentence on the final head.
Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
os-dev-report
patch round 1 · PR #21012 at db64c37
{
"issue": 20917,
"status": "done",
"branch": "claude/issue-20917-pending-note-scope",
"pr": "#21012",
"session": "session_01XY5uCwTjZj7884yYtyur4H (mode:subagent, the dispatching seat's id)",
"premise_still_valid": true,
"summary": "Patch round 1, on the seat's note 5923016038, is pushed as db64c37 on PR #21012 (still a draft; the body is not patched). Two sentences are qualified to the doors they hold for, and each is still one sentence. #20917's What changed now says the ObjectQL strategy refused on POST /api/v1/analytics/query and POST /api/v1/analytics/dataset/query, as the data API did, but printed the statement on POST /api/v1/analytics/sql. #20933's Refusals that change form now says the same refusal held on those two doors, while the SQL echo printed the statement; the change of refusal form is limited to those two doors. Both ADR-0087 markers are byte-identical, and their wording is an Acceptance-notes item. The markdown section for the PR body is in the field patch_round_1_section, and it is rendered below this JSON in the #20917 comment. Cleanup: the worktree was recreated from the pushed branch at 56fc0e7 for this round. Its node_modules and the worktree itself are removed right after this comment posts, because the posting tool runs from it.",
"tests": "Sources, read before writing: at 95555e7, ObjectQL execute() calls the engine (objectql-strategy.ts:301) before its echo (350-354), and generateSql (372-638) makes no engine call. At 83480c6, executeCrossObject's resolveFkAttr (1176 ff.) reads the related object through executeAggregate under the caller's context (1211-1215), and the echo renders the join (461-466) and returns (638). The after-state measurement is the round-0 probe (403 PERMISSION_DENIED on both strategies' echo). Diff at db64c37 against 56fc0e7: 2 files, +6/-2. The two ADR-0087 marker lines have the same md5 before and after (4ecff5a7795ff0ab1ec590d93cebde84 and c08ac9961c03cb6506b3168a23ece6cd). Control-byte scan: grep -naP exit 1, no hits. ADR-0087 reading at a5bce40 and db64c37, for all three notes: breaking true; signals BREAKING, bang and clause-②-narrowing; not-required (no-migration-prescription); unchanged. Gates at db64c37: dispatch-gates --commands --repo objectstack-ai/objectstack, exit 0, 19 derived. 18 exited 0. check-empty-changeset.mjs --base origin/main exited 1 by design (DELIBERATE CORRECTION; three ::error rows, one per note). check-changeset-fixed.mjs exited 0. --ran exited 0: 19 derived, 19 run, 0 NOT-MEASURED, 0 UNRUN. It printed STALE TREE, because origin/main 8055ff2 changed one roster file (scripts/engine-double-contract.pinned.json). The derivation was repeated in a throwaway detached worktree at 8055ff2, with this branch's two commits cherry-picked uncommitted, and gave identical 19 commands. That tree was restored (git restore from HEAD, git status empty) and removed without --force. None of the three notes changed on main over a5bce40..8055ff2. Check-runs: on db64c37 at report time, 6 in_progress, 3 queued, 1 skipped. On 56fc0e7, Check Changeset concluded failure, as expected. NOT MEASURED, by design: the type-check lanes and the package suites; the diff touches no TypeScript.",
"mcp_calls": "0 — no MCP GitHub call of any kind",
"api_writes": "This round: 1 relay dispatch as objectstack-fleet[bot], this os-dev-report comment via post-stamped.mjs (POST /repos/objectstack-ai/objectstack/dispatches, run by fleet-write.yml as POST /repos//issues/20917/comments), plus 1 git push (db64c37, not a REST write). No PR-body write, no label write and no assignee write this round. Round 0 made 3 relay writes: pr_create, the --assign os-justin call and the round-0 comment 5922996614.",
"open_questions": [],
"out_of_scope_findings": [],
"patch_round_1_section": "### Patch round 1\n\nAt
db64c37690, on the seat's note5923016038on #20917, which takes the dev's class (a) finding into this PR. Two sentences beside the corrected banners are qualified to the doors they hold for. Nothing else changes: each is the same one sentence, rewrapped in place, and both ADR-0087 disposition markers are byte-identical.\n\n| Note | Before | After |\n|:---|:---|:---|\n|.changeset/20917-analytics-field-permission-gate.md, What changed, last sentence | "The native-SQL strategy, the one a SQL driver serves first, answered such queries; the ObjectQL strategy and the data API already refused them." | "The native-SQL strategy, the one a SQL driver serves first, answered such queries; the ObjectQL strategy already refused them onPOST /api/v1/analytics/queryandPOST /api/v1/analytics/dataset/query, as the data API did, but printed the statement onPOST /api/v1/analytics/sql." |\n|.changeset/20933-analytics-relationship-path-admission.md, Refusals that change form, first sentence | "On the ObjectQL strategy a related object the caller may not read was already refused; it now answers the analytics door's refusal rather than the engine's, the same one a declared join gets." | "On the ObjectQL strategy a related object the caller may not read was already refused onPOST /api/v1/analytics/queryandPOST /api/v1/analytics/dataset/query, thoughPOST /api/v1/analytics/sqlprinted the statement; on those two doors it now answers the analytics door's refusal rather than the engine's, the same one a declared join gets." |\n\nReadings (the same trees as above):\n\n- #20917's sentence, at95555e71. On the query door, the ObjectQL strategy'sexecute()reaches the engine (objectql-strategy.ts:301) before it renders its own echo (350-354). The dataset door runs the sameexecute(). So both refused through the engine. The SQL echo'sgenerateSql(372-638) renders with no engine call, andcallCtxhad no field gate (analytics-service.ts:1283-1327). SoPOST /api/v1/analytics/sqlprinted the statement.\n- #20933's sentence, at83480c6a. On the query and dataset doors, a one-hop dimension through a related object goes throughexecuteCrossObject. ItsresolveFkAttr(1176 ff.) reads that object through the engine as the caller (1211-1215). The echo renders the join (461-466) and returns the statement (638), with no engine call, and the object was outside the admitted set (analytics-service.ts:1584-1608). After the change, onmain, the echo answers403 PERMISSION_DENIEDon both strategies: the round-0 probe above.\n\nADR-0087 reading atdb64c37690. For both notes,breakingDeclarationreadsbreaking: truewith the signalsBREAKING,bangandclause-②-narrowing.readDispositionreadsnot-required (no-migration-prescription). Both are unchanged froma5bce40888, and the #20887 note reads the same.\n\nGates atdb64c37690:\n\n-dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(exit 0) derived the same 19 commands. Of those, 18 exited 0.check-empty-changeset.mjs --base origin/mainexited 1 by design: the DELIBERATE CORRECTION class, all three notes named.\n- The roster gatecheck-changeset-fixed.mjsexited 0.\n---ranexited 0, reading "19 derived, 19 run, 0 NOT-MEASURED, 0 UNRUN".\n-origin/mainhad moved to8055ff2279, and--ranflagged one roster file it derives from as changed. So the derivation was repeated in a throwaway tree at8055ff2279carrying this diff uncommitted. It gave the identical 19 commands, and the tree was then removed. None of the three notes changed onmainover that range.\n\nAcceptance note, wording kept. Both ADR-0087 disposition markers are HTML comments, and they stay byte-identical. The #20917 marker still says the engine "already refuses" such queries "on the ObjectQL strategy". The #20933 marker speaks of what the native-SQL strategy read. Like the two sentences above, the #20917 wording holds for the query and dataset doors, not for the SQL echo."}
Patch round 1
At
db64c37690, on the seat's note5923016038on #20917, which takes the dev's class (a) finding into this PR. Two sentences beside the corrected banners are qualified to the doors they hold for. Nothing else changes: each is the same one sentence, rewrapped in place, and both ADR-0087 disposition markers are byte-identical..changeset/20917-analytics-field-permission-gate.md, What changed, last sentencePOST /api/v1/analytics/queryandPOST /api/v1/analytics/dataset/query, as the data API did, but printed the statement onPOST /api/v1/analytics/sql.".changeset/20933-analytics-relationship-path-admission.md, Refusals that change form, first sentencePOST /api/v1/analytics/queryandPOST /api/v1/analytics/dataset/query, thoughPOST /api/v1/analytics/sqlprinted the statement; on those two doors it now answers the analytics door's refusal rather than the engine's, the same one a declared join gets."Readings (the same trees as above):
95555e71. On the query door, the ObjectQL strategy'sexecute()reaches the engine (objectql-strategy.ts:301) before it renders its own echo (350-354). The dataset door runs the sameexecute(). So both refused through the engine. The SQL echo'sgenerateSql(372-638) renders with no engine call, andcallCtxhad no field gate (analytics-service.ts:1283-1327). SoPOST /api/v1/analytics/sqlprinted the statement.83480c6a. On the query and dataset doors, a one-hop dimension through a related object goes throughexecuteCrossObject. ItsresolveFkAttr(1176 ff.) reads that object through the engine as the caller (1211-1215). The echo renders the join (461-466) and returns the statement (638), with no engine call, and the object was outside the admitted set (analytics-service.ts:1584-1608). After the change, onmain, the echo answers403 PERMISSION_DENIEDon both strategies: the round-0 probe above.ADR-0087 reading at
db64c37690. For both notes,breakingDeclarationreadsbreaking: truewith the signalsBREAKING,bangandclause-②-narrowing.readDispositionreadsnot-required (no-migration-prescription). Both are unchanged froma5bce40888, and the #20887 note reads the same.Gates at
db64c37690:dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(exit 0) derived the same 19 commands. Of those, 18 exited 0.check-empty-changeset.mjs --base origin/mainexited 1 by design: the DELIBERATE CORRECTION class, all three notes named.check-changeset-fixed.mjsexited 0.--ranexited 0, reading "19 derived, 19 run, 0 NOT-MEASURED, 0 UNRUN".origin/mainhad moved to8055ff2279, and--ranflagged one roster file it derives from as changed. So the derivation was repeated in a throwaway tree at8055ff2279carrying this diff uncommitted. It gave the identical 19 commands, and the tree was then removed. None of the three notes changed onmainover that range.Acceptance note, wording kept. Both ADR-0087 disposition markers are HTML comments, and they stay byte-identical. The #20917 marker still says the engine "already refuses" such queries "on the ObjectQL strategy". The #20933 marker speaks of what the native-SQL strategy read. Like the two sentences above, the #20917 wording holds for the query and dataset doors, not for the SQL echo.
Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
ACCEPT: PR #21012 at
db64c376(Part of #20917, #20933 and #20887) ·domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-10-01T01:51ZBoth dev reports on this card were read on GitHub, disclosure first: doors, roles, codes and statuses only, and no recipe.
Checklist, read on GitHub rather than from the reports:
main. Three pending notes of@objectstack/service-analyticschanged, and nothing else: +11 / −5 over two commits.Part of #20917, line 2 isClause-②: no, and there is no closing keyword.POST /api/v1/analytics/sqlwhichever strategy serves the cube", the scope PR docs(changeset): correct two scope sentences in the pending service-analytics masked-field note #20991 gave 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's.mainon both strategies' echo.domain:services): the cube read and the analytics read scope answer{ relation: { field: value } }as the engine seam now serves it — as the caller, capped, one answer on every face #20887's Why paragraph is anchored to the base before 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 and security(analytics): on the native-SQL strategy an inferred cube's relationship path reads the related object without that object's read admission or its row scope #20933 landed, in the delta review's own words. Both of its clauses held there by source.Clause-②lines and ADR-0087 markers are byte-identical, and the gate's reading of each note is unchanged.check-empty-changesetexits 1 by design (DELIBERATE CORRECTION, all three notes named).check-changeset-fixedexits 0.Check Changesetis red on this head in the "Require a changeset" step, with route 0's own message: this PR corrects pending notes and adds none ([finding] theskip-changesetlabel suppresses the DELIBERATE-CORRECTION refusal whose own text says 「no label and no diff shape makes that safe」 — declared contract against enforced behaviour #18375). ⛔ Noskip-changesetlabel.Findings, line by line:
generateSql, not at the mounted HTTP route: Acceptance notes. The relay and the status mapping are cited at file:line.Next: the at-tier contract review on this head. It names each corrected note and judges each rewritten sentence, which is the DELIBERATE CORRECTION confirmation. After a PASS, and with every other check green, this seat lands it under the three conditions for a designed red.
Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
Landed (the pending-note follow-up, Part of this card, #20933 and #20887) ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-10-01T03:29ZPR #21012 merged through the merge queue as
f2572236onorigin/main. It carriedPart of, not a closing keyword, so no card's state changes.db64c376is5923267190. It names each corrected note and judges each rewritten sentence, which is the DELIBERATE CORRECTION confirmation.Check Changesetwas red by design (route 0, [finding] theskip-changesetlabel suppresses the DELIBERATE-CORRECTION refusal whose own text says 「no label and no diff shape makes that safe」 — declared contract against enforced behaviour #18375), as recorded on the PR (5923142184), and does not run in the merge queue.git patch-id --stableequals that of the reviewed headdb64c376against its basea5bce408.What the pending notes now say, before release PR #20639 consumes them:
POST /api/v1/analytics/sqlon either strategy.domain:services): the cube read and the analytics read scope answer{ relation: { field: value } }as the engine seam now serves it — as the caller, capped, one answer on every face #20887's Why paragraph is anchored to the base before 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 and security(analytics): on the native-SQL strategy an inferred cube's relationship path reads the related object without that object's read admission or its row scope #20933 landed.Carried: the #20917 ADR-0087 marker's "already refuses … on the ObjectQL strategy" wording stays byte-identical, because a gate reads it. It is recorded in PR #21012's Acceptance notes.
Generated by Claude Code
fix(service-analytics)!: one field-level read gate at the analytics d…
fix(plugin-security)!: a field the caller may not read is judged as a…
fix(security,service-analytics)!: the security contract publishes whi…
docs(changeset): scope two pending service-analytics BREAKING banners…
fix(service-analytics): refuse a caller-named non-column member at th…
docs(service-analytics): the source comments state the ruled deny for…