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
Triage: first grade —
bug·security·priority:p0·domain:services·area:access·pm:queue. Direction: one admission gate judges every named member with the engine's own field-permission rule, before either strategy runsTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-09-30T18:51Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ No request body, header, field spelling or returned value here: this card's disclosure discipline, which binds every comment and PR that follows.Triage:
packages/services/service-analytics(the admission step andNativeSQLStrategy) ⇒domain:services.Why p0. A measured disclosure of data the caller's field-level permissions hide, at a public route, on the default SQL deployment.
403byengine.findand by the analytics ObjectQL strategy.On the release. The gap reads as long-standing: it is present in the published 17.5.0 as read, not introduced by an unreleased change. So holding Version Packages PR #20639 would not reduce exposure. The fix ships in the first release after it lands. The maintainer is told directly.
Direction (triage's call between the card's two arms): judge at admission.
engine.finduses. An unreadable member answers the engine's403 PERMISSION_DENIED, in the engine's words./analytics/sql) passes the same gate. Measure first whether it prints a hidden member today.Pins. Each on both strategies:
Coordination. It shares the package with #20887 / PR #20916 and #20912 (blocked). This card goes first; the others rebase on it.
findingcomes off at grading.objectstack-fleet commentedon Sep 30, 2026
Claim: PM loop round 1 · 2026-09-30T19:01Z
Session:
session_01XY5uCwTjZj7884yYtyur4HAccount:
os-justin(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-20917-analytics-field-permission-gateWorktree:
objectstack-issue-20917Domain:
domain:servicesSeat:
domain:services(seat 1, seat post #6021)File surface:
packages/services/service-analytics/src/**:packages/rest/src/analytics-*.test.ts,packages/runtime/src/analytics-*.test.ts).packages/client/src/envelope-caller-census.test.ts(domain:cli): ledger rows only, for eachanalytics.query(call site a new pin adds. That is the census's own remedy; #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 amendment5917250005is the precedent..changeset/20917-*.md.content/docs/**(domain:devx): only a page that states how the analytics routes treat field permissions, if one does.packages/objectql/src/**,packages/spec/src/**orpackages/plugins/plugin-security/src/**. The field-permission readerengine.finduses is called, not changed. If the analytics layer cannot reach it without an export change, the dev stops and reports.(stop on a breach outside these; explain in the report)
Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable). The work is one gate at one admission step, reusing the engine's reader, with the pins triage named. The default tier fits. The contract review runs atCONTRACT_REVIEW_TIER.Clause-②: no
no: the card refuses what the native path answers today, and it widens nothing. The expected line 2 isno (narrowing), which is BREAKING.Thread-read: 5917636106
Serial constraints cleared:
5917636106): this card goes first inservice-analytics.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, this seat, in a patch round) holdsanalytics-service.tsand both strategies. It keeps authoring, and it lands after this card's PR, mergingmainthen (⛔ no rebase after a push).numberas a string (count: "2"), where SQLite answers 2 — the class #20335 closed at the engine door #20889 (pm:queue) and [finding] analytics NativeSQL answers a multi-value dimension one group per serialized array on SQLite and 500 on PostgreSQL, and acount_distinctover a JSON-stored field 2 / 500; the engine door #20808 adds does not see it #20912 (pm:blocked) wait behind both.service-analytics/src, the analytics route pins or the census. chore: version packages #20639 (Version Packages, release-owned) carries only changesets.pm:dispatchedclaims, none other namesservice-analytics. #5930 step 4 (domain:engine): the engine-fed faces delete their hand-copied filter meaning (driver-sql, turso remote, memory query, mongodb, formula,having); the memory reference matcher retires (D6) #20822 and feat(rest): 导出接口新增 ?template=true —— 输出只含「可填列」的 xlsx 导入模板 #18386 nameobjectql/src/engine.ts, which is excluded here.area:accessaxis: security(flows): move a flow's inbound-hook secret out of flow metadata into the write-only secret seam #7799 established — no read, the generic data door included, returns it #20790 (pm:queue) is disjoint by file (service-automation,trigger-api,metadata-protocol).Generated by Claude Code
fix(service-analytics): the field reader is always the security servi…
objectstack-fleet commentedon Sep 30, 2026
os-dev-report
{
"issue": 20917,
"status": "done",
"branch": "claude/issue-20917-analytics-field-permission-gate",
"pr": "#20931",
"session": "session_01XY5uCwTjZj7884yYtyur4H (mode:subagent, the dispatching session's id)",
"premise_still_valid": true,
"summary": "One field-level read gate now runs at the analytics door (AnalyticsService.callCtx, right after the object-level admission, before strategy selection; the draft-preview branch asks it too), so the native-SQL strategy, the ObjectQL strategy, the SQL echo and any later strategy inherit it by construction. Every member a query names (dimensions, measures, time dimensions, where members, order keys, joined members with their relationship fields, a dataset's own and its requested measures' filters) is resolved to the (object, field) it reads, authored aliases by the field they resolve to, and a field outside the caller's readable fields answers 403 PERMISSION_DENIED in the engine's own two refusal texts, byte-equal to engine.aggregate / engine.find for the same field (pinned against the live engine). The permission rule stays the security service's: the plugin bridges the new optional AnalyticsServiceConfig.getReadableFields hook to ISecurityService.getReadableFields at call time (absent service: no gate; unusable service: refused fail-closed); nothing in objectql, spec or plugin-security changed. Measured Clause-② is yes (narrowing), not the claim's expected no (narrowing): the new optional config member is a public-surface addition, the narrowing is the native path's refusals (both directions written out in the PR body); changeset minor, BREAKING banner, ADR-0087 not-required (no-migration-prescription). Deviations: (1) the definition's empty-branch push probe was skipped because the order forbids any push before the fix sits on the red pins; the first push carried pins + fix together. (2) The PR body was written once; its Acceptance-notes bullet on the reader difference is the first-draft class-level wording (a vaguer rewording I meant to send did not apply, my own shell quoting). If the seat prefers it, replace that bullet with: 'The gate is exactly as wide as getReadableFields, the published reader. Where that reader and the engine's own field guard differ, the gate follows the reader; the difference is reported separately, as a class, in the dev report.' Main moved to aaad682 during the work; no commit touched this surface, so no merge. CI not awaited (in_progress at report time).",
"tests": "Pins committed red first (77f7370), fix on top (40c4979), changeset + plugin tidy (992a592); pushed only after the fix commit. Route pin packages/rest/src/analytics-field-permission-gate.test.ts (real SecurityPlugin + ObjectQL + SqlDriver, both strategies, cube read + SQL echo over inferred and authored cubes, dataset door via route): base 6 red / 6 green (the 3 position tests per strategy red; references and controls green); fixed 12/12. Unit pin service-analytics src/tests/field-read-admission-gate.test.ts: with the three source files restored to the pins state (absolute-path restore, blob == HEAD, git diff HEAD empty) 39 red / 9 green (stand-down and no-reader controls); fixed 48/48. Ablation A1 at 40c4979, predicted before running (route 6 red / 6 green; unit 38 red / 10 green): gate call replaced via scripts/ablation-replace.mjs (anchor x1->x0, blob 3893e2a1 -> 8c213dfc), service-analytics rebuilt, ablation-dist-preflight marker present in 2 built files; observed exactly 6/6 and 38/10; restore: blob == HEAD, git diff HEAD empty, tree clean, rebuilt, preflight --absent: marker absent from all 6 built files. At 992a592: service-analytics test 146 files / 3374 passed; typecheck exit 0, 144/144 tests files in the tsc program (--listFiles). rest: 12 analytics-* files + error-response-structured-arm-door-parity = 13 files, 215 passed / 3 skipped; rest typecheck incl. check:test-typecheck OK. Consumer sweep (files loading this package): runtime analytics-* x3 + cross-field-refusal-operand-withhold 28 passed / 4 skipped; client analytics-automation-json-erasure + envelope-caller-census 27 passed (no new census site: pins call the producer via a receiver named service); dogfood analytics-* x6 54 passed. Gates: dispatch-gates --commands derived 62 (same 62 as at dispatch); all 62 + 4 roster families (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity) exit 0; --ran with exit codes: 62 derived, 62 run, 0 NOT-MEASURED (derived zero). check:dual-build-cjs-loads first exit 3 (prerequisite) and check:type-check-debt cut by my runner timeout, both re-run exit 0 after turbo run build over ./packages/. check-adr-0087-registration: 1 declared-breaking changeset, not-required (no-migration-prescription) accepted. Lint narrowed and proved at 992a592: population = the 5 changed .ts files; isPathIgnored false for all 5; parserOptions.project/projectService unset for all 5 (no type-aware lint); eslint --no-inline-config: 5 files, 0 errors, 0 warnings. NOT MEASURED: live PostgreSQL (package matrix runs no live SQL server; the gate answers before any statement is compiled); full rest local project and whole-workspace typecheck (left to CI). Private per-position before/after measurements (C4): scratchpad/issue-20917/probe3-before.log, probe3-after.log, probe-c4-before-2.log, probe-c4-after.log.",
"mcp_calls": "0",
"api_writes": "3 REST writes, each through one fleet-write repository_dispatch executed as objectstack-fleet[bot]: POST /repos/objectstack-ai/objectstack/pulls (draft PR #20931, run 36771622582); POST /repos//issues/20931/assignees (os-justin, via label-write.mjs, run 36771737615, read back matched); POST /repos//issues/20917/comments (this os-dev-report, via post-stamped.mjs). Plus 3 git pushes to claude/issue-20917-analytics-field-permission-gate (not REST).",
"open_questions": [
{
"question": "An authored cube member whose sql is an EXPRESSION (CASE WHEN ..., SUM(...) / 2) names no field the gate can attribute. The gate stands down on it (pinned), so an expression over a field the caller may not read is served on the native-SQL strategy (the ObjectQL strategy refuses expression measures outright). Triage's words ('judges the underlying fields a member resolves to') do not settle an expression. Keep the stand-down, or judge the expression's identifier tokens?",
"options": [
"A. Keep the stand-down: an authored expression is the cube author's declaration of a derived value, the cube-layer analogue of a formula field, whose disclosure is governed by its declaration, not its inputs' field permissions. Cost: none now; the AI-authoring risk stays (an expression over a hidden field is served without a word).",
"B. Judge identifier tokens of the expression that the object's declared field list carries (string literals skipped), refusing when any is not readable. Cost: a small SQL tokenizer in the analytics layer (a second parser of expression text), false refusals when an identifier coincides with a hidden field's name, and a new refusal for authored cubes no measured app has.",
"C. Refuse an expression member outright for any caller who has at least one unreadable field on the object. Cost: over-broad; refuses expressions that touch no hidden field."
],
"recommendation": "A, on the four axes. Real business need: measured zero producers (examples/app-showcase's one cube expression, done_rate, reads the readable status field; no in-repo cube expression reads a permission-hidden field). Long-term soundness: an expression is authored metadata, the formula-field analogue; B plants a second parser of SQL text in the analytics layer, the drift shape contract-first avoids. AI-proofing: B is stronger (a loud refusal instead of a silent disclosure), which is the real cost of A; the contract-first answer to that risk is an authoring-time check that an analytics_cube expression names only fields, which is a new gate and therefore default no unless the maintainer names it. No scope spread: A adds nothing. The trade is AI-proofing (B) against the other three axes (A); the maintainer's call."
}
],
"out_of_scope_findings": [
"class: a (and b: the maskingRule field contract states masked callers cannot filter, sort, group or aggregate on the field) · reach: exception: security (could leak data) · a field the caller sees PARTIALLY MASKED (a maskingRule field whose unmask requirement the caller does not hold) is answered unmasked as a grouped or filtered member on the native-SQL strategy, where engine.find / engine.aggregate and the ObjectQL strategy refuse it 403; ISecurityService.getReadableFields reports such a field readable (a served, masked column) and the published contract carries no reader for 'masked for this caller', so this card's gate (bounded by that reader) cannot see it; closing it needs a contract member in the spec + plugin-security lanes · Seam: spec:FieldSchema.maskingRule → runtime:NativeSQLStrategy via AnalyticsService admission · measurement private: scratchpad/issue-20917/probe-c4-before-2.log and probe-c4-after.log (q_masked_ rows) · dedupe words: analytics partially masked field group by native SQL · maskingRule getReadableFields queryable · masked field filter oracle analytics",
"class: a · reach: exception: security (could leak data); public door: the data API (engine.find), measured privately · the engine's field guard (plugin-security predicate guard) does not judge a cross-field comparand ($field) that names a field the caller may not read: a filter comparing a readable field with a hidden one is served instead of refused 403, a comparison oracle; analytics inherits it because the native strategy declines cross-field comparisons to the engine · Seam: spec:FilterCondition $field operand → runtime:plugin-security predicate-guard collectConditionFields · measurement private: scratchpad/issue-20917/probe-c4-before-2.log (engine_find_field_operand, q_field_operand) · dedupe words: predicate guard $field operand hidden field · cross-field comparison field-level security · collectConditionFields field reference",
"class: a · reach: exception: security (could leak data); measured privately on the analytics cube read (what POST /api/v1/analytics/query relays) · on the native-SQL strategy an inferred cube's relationship path joins the related object with neither the object-level read admission nor the related object's row scope: the member reads an object the caller holds no read grant on, and values of related rows outside the caller's row scope, where the ObjectQL strategy refuses the first 403 and buckets the second as restricted; object/row level, not field level, so outside this card (the gate here judges fields only) · Seam: runtime:AnalyticsService.queryObjects/cubeObjects (the admitted and scoped object set omits a traversal alias) → NativeSQLStrategy.qualifyAndRegisterJoin synthetic join · measurement private: scratchpad/issue-20917/probe2.log · dedupe words: analytics inferred cube relation traversal read scope · synthetic join object admission native SQL · cubeObjects traversal alias unscoped",
"carrier: PR #20916 (#20887) · noted, not filed: #20916 merges main after this lands; its nested-relation decline then sees only permitted queries, and its normalizer change (relation node instead of dotted leaves) should keep feeding this gate's where-member collection (collectFilterLeaves) or the engine judges the form on the ObjectQL path it routes to",
"carrier: none · noted, not filed: MemoryAnalyticsService (driver-memory's own cube face) is not touched and its field-level answer is not measured here"
]
}
Generated by Claude Code
2 remaining items
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 — 🟢 marchtian · session_01Nk17Z26X5DB59iQztDBADH · p1–p3, serial (maintainer) #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…