Repository navigation
Description
Measured while pinning the FLS read side for #9481 (the pin itself deliberately asserts nothing about this, so that a future fix does not read as a regression).
What was measured
Boot: stock showcase, dogfood harness, app-default security wiring. A scratch permission set grants a fresh member exactly one thing:
{ "showcase_project.budget": { "readable": false, "editable": false } }GET /api/v1/data/showcase_project/:id as that member returns 200 with budget correctly stripped — the key is absent, which is the documented and now-pinned behaviour. The same payload also carries:
spent: 60000 (not enumerated by the set, so readable — correct)budget_remaining: 90000
showcase_project.budget_remaining is a formula field:
(record.budget == null ? 0 : record.budget) - (record.spent == null ? 0 : record.spent)
so the denied value is recoverable exactly: budget = budget_remaining + spent = 150000. Confirmed against the system-context row, which reads budget: 150000.
Why this is worth a card rather than a shrug
The runtime already treats indirect disclosure of an FLS-denied field as in scope, and says so in its own refusal text. Filtering or sorting on the stripped field answers 403 PERMISSION_DENIED with:
query on 'showcase_project' references field(s) not readable by the caller: budget. Filtering, sorting, grouping, or aggregating by a hidden field would leak its values (filter oracle) — remove these predicates or grant field read access.
So the filter oracle is closed by the product, deliberately, while a formula that arithmetically republishes the same value is served. The two are the same disclosure class; only one is guarded.
The mechanism is consistent with the documented model — getFieldPermissions is an allow-list over enumerated fields, and budget_remaining was not enumerated — so this may well be authoring responsibility rather than a runtime bug. That is exactly the judgement this card is asking for.
Readings
- Authoring responsibility, enforced at author time. A lint rule in the ADR-0049 declared-equals-enforced family: a formula (or summary) field that references field F must not be readable by a persona for whom F is
readable: false, unless the author says so explicitly. Fits the "make it hard to get wrong at authoring time" direction and costs no runtime work. - Runtime derivation-closure. Strip a formula field whenever the caller is denied any field its expression reads. Correct but expensive and possibly surprising — a rollup over a restricted column would vanish for most personas.
- Documented as-is. State in the permissions docs that FLS is per-declared-field and does not close over derivations, so authors know they must deny the derived fields too.
Reading 1 looks right to me on the three axes, but the call is the maintainer's, not mine.
Not covered by anything today
- No checklist item covers it:
access-security.fls-mask-and-strip's clauses are about the field's own read/write, and its read half is now pinned bypackages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.tsfor the STRIP mechanism only. - Not the same thing as security explain: the field-mask layer does not report partial masking (maskingRule) — gated fields read as fully hidden, gate-less rule fields as fully readable #9127 (explain-surface reporting of partial masking), which is closed.
- Independent of Land the showcase seed fixtures that unblock the checklist's stock-fixture gaps (loginable 2nd member, publicSharing object, writable summary field, readable:false FLS grant) #9308 — that card lands a stock
readable:falseseed grant; this behaviour is present with or without it.
Filed unassigned from the #9481 dev seat, out of that card's scope. Scope note for triage: #9481 is not fixed or changed by this, and #9308 remains open independently.
Activity
os-support-ai commentedon Aug 18, 2026
Triage: lands in the decision box —
needs-user-decision+security+domain:engine-core, type Task. This is a security/permission-boundary fork (which disclosures FLS closes over), so it sits on the manual floor regardless of how aligned the analysis is; ⛔ not auto-adjudicated. (Triage seat, sessionsession_0185aJyu6TRnHbcVgSfGPSho.)Rationale: the runtime already refuses the filter-oracle form of indirect disclosure with an explicit refusal text, while serving the formula-republish form of the same class. Whichever reading wins, that asymmetry is the thing being ruled on. Landing domain is
engine-core(formula evaluation / FLS strip seam); if Reading 1 wins the enforcement lands as an authoring-time gate and the card re-routes at dispatch.Four-facet block:
budgetexactly recoverable frombudget_remaining + spent); the product's own refusal text already claims indirect disclosure is in scope. No customer report yet, but FLS is a shipped compliance surface — this is the class of thing that gets discovered in an audit.Recommendation: Reading 1 (authoring-time enforcement), with Reading 3's one-line docs statement landed immediately as the interim truth. Reading 2 only if the maintainer wants the runtime to guarantee closure regardless of authoring.
Generated by Claude Code
os-support-ai commentedon Aug 18, 2026
Maintainer ruling (2026-08-18, live PM chat with the triage seat, session
session_01EUZUjvnLsqwHVkXkTv8TKQ) — recorded verbatim: 「9562 不处理」, clarified 「9562 9513 都是直接关闭」.Closing as not planned. The measurement stays on record: FLS
readable:falsedoes not close over formula derivations, and the maintainer has decided not to act on it now (none of the three readings — authoring-time lint, runtime derivation-closure, docs statement — is being commissioned). The card is the record; reopening is free if a deployment hits this as a real disclosure complaint or the maintainer revisits. The filter-oracle guard and the pinned STRIP behaviour are unaffected.Generated by Claude Code