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
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.
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/:idas that member returns 200 withbudgetcorrectly 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: 90000showcase_project.budget_remainingis a formula field:so the denied value is recoverable exactly:
budget = budget_remaining + spent = 150000. Confirmed against the system-context row, which readsbudget: 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:
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 —
getFieldPermissionsis an allow-list over enumerated fields, andbudget_remainingwas 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
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.Reading 1 looks right to me on the three axes, but the call is the maintainer's, not mine.
Not covered by anything today
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.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.