Skip to content

fix(approvals): apply the subject object's field-level read controls to the payload snapshot at serve time - #11039

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-10749-approval-payload-read-masking
Aug 22, 2026
Merged

os-warren merged 2 commits into
mainfrom
claude/issue-10749-approval-payload-read-masking

Conversation

@os-warren

@os-warren os-warren commented Aug 22, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #10749.

Applies the subject object's field-level read controls to the approval
payload snapshot at serve time, so an approver no longer receives fields the app
author declared they may not read. Implements the maintainer's 2026-08-22 ruling
(Option B — read-time masking, consumer inventory first). Remaining scope, and
why it is not discharged here, is in Open question at the bottom.

Draft, with needs:contract-review on the card: what a published read returns to
a non-admin changes. It is a narrowing — nothing here widens what any caller
receives.


Mechanism

sys_approval_request.payload_json stores the submitted record's raw row,
captured from the flow's $record variable, which the automation layer hands
over with the record's own FLS never applying. The column is a textarea on
sys_approval_request, so to every read door it is an opaque string: the
field-visibility machinery governs columns of objects and cannot see inside a
JSON column. Every field-level read control declared on the SUBJECT object —
requiredPermissions (ADR-0066 D3), a permission set marking a field
non-readable, a maskingRule — was therefore unenforceable on the approval
path, for every app.

The full snapshot stays at rest. Redaction happens at serve time, keyed on
the reading caller, so the same row answers an admin with the whole snapshot and
a restricted approver with only the fields they may read. The readable set is
not recomputed: it comes from the security service's getReadableFields,
documented as the same field mask the read middleware applies, so this seam
cannot drift from data-plane FLS.

Object-level access is deliberately untouched. An approver commonly holds no
read grant on the object under approval at all — that is the normal shape of an
approval, and the snapshot is how they see what they are approving.
getReadableFields answers a caller with no field-permission entries with the
full field set, so field-level declarations are enforced while every approval
drawer shipping today keeps rendering.


STEP 1 — the inventory

Consumers of sys_approval_request.payload_json

A repo-wide scan for payload_json returns 53 hits, and that is not a
consumer count
: the identifier is a field name on three unrelated objects —
sys_job_queue (service-queue), http_delivery (service-messaging) and
sys_approval_request. Classified individually, hits on the approval object's
column are these, and only these:

# Site What it is Loses a field it depends on?
1 approval-service.ts:415 (rowFromRequest) The mapper turning the row into the served payload Yes, by design — this is the leak, and door 1
2 approval-service.ts:2267 Approver re-resolution at decide time (expandApprovers) No — raw comes from loadPendingRow, a SYSTEM_CTX read; the isSystem skip leaves it whole
3 approval-service.ts:4096 Free-text search filter { payload_json: { $contains: q } } No — a predicate, not a serve. See Out of scope
4 sys-approval-request.object.ts The field declaration + a doc comment n/a
5 record-reader-visibility.test.ts, approval-resume-relation-expand.test.ts Test fixtures Fixtures, re-checked; both still green

Consumers the ruling asked about specifically, each checked rather than assumed:

  • Notifications — the object's own doc comment claims the snapshot is "used
    by notifications so they can render before the record is locked or changed".
    Measured against all 12 this.notify(...) call sites: every payload is
    {title, message, actionUrl} (two also actions). None reads the
    snapshot.
    The doc comment is stale; filed as [finding] sys_approval_request.payload_json's doc comment claims notifications render from the snapshot — measured false against all 12 notify call sites #11041 rather than edited here.
  • Actionable-link pages (ADR-0043 magic links) — renderConfirmPage /
    renderResultPage render summaryRows, which reads record_title,
    object_label, submitter_name, record_id, object_name. Not a payload
    door.
  • Replay / resume — the flow-resume path correlates on
    flow_run_id/flow_node_id and reads node_config_json, not payload_json.
  • Reporting / audit export — no reader. Searching all of packages/,
    apps/ and examples/ for sys_approval_request returns 16 non-test files
    outside plugin-approvals; none of them appears in the payload_json
    scan
    — they are constants, i18n, lint rules, nav contributions and seeds.
    The instrument was proven on a corpus where the term is known to exist (68
    hits inside plugin-approvals) before the zero elsewhere was read as
    evidence.

Verdict: no existing consumer loses a field it depends on.

Serve doors, and how they were enumerated

Enumerated by asking what can return this column's bytes to a caller, then
tracing each candidate to its producer, rather than by counting matches.

Door How reached Covered
1. Service door GET /api/v1/approvals/requests and …/:id → listRequests / getRequest → rowFromRequest → the parsed payload (plus payload_display / payload_labels) Yes
2. Generic data door The object declares enable.apiMethods: ['get','list'], so a plain find/findOne returns the raw string with the service never running. This is a family — REST data routes, ObjectQL, the CSV/XLSX export route (a read-derived projection), the MCP data tool, the AI-context interceptor — all sharing one producer: find on the engine Yes, at that producer
3. Actionable-link pages Renders summary fields only n/a — not a door
4. Notifications Carries no snapshot n/a — not a door
5. System/replay reads SYSTEM_CTX; the audit channel Deliberately whole

Door 2 is registered as object-scoped engine middleware, not an afterFind
hook
, and that choice is load-bearing: a hook receives Engine.buildSession's
output, which carries no onBehalfOf. getReadableFields intersects the
delegator's field mask on an on-behalf-of read (ADR-0090 D10, fail-closed on a
dangling delegator), so a hook-based seam would silently drop that intersection
and answer a delegated read more permissively than the service door.
Middleware carries the real ExecutionContext, so both doors narrow
identically.

The two doors are pinned independently — proven by ablation below, not
asserted.


What is pinned

packages/plugins/plugin-approvals/src/approval-payload-redaction.test.ts,
16 tests. The four directions, each asserted in the direction that can fail:

  1. Leak closed — a restricted approver does not receive salary, ssn,
    internal_notes. Asserted by name; a shape assertion would pass against
    a redactor that removed the wrong keys.
  2. Audit preserved — after a restricted read, the stored column still parses
    to the whole row, asserted against the table separately from anything
    served. A write-time trim would pass a leak-only suite while silently taking
    Option A.
  3. Still-served — the approver still receives id/title/amount/
    submitted_by. A seam that redacted everything would also score green on a
    leak-only suite and break every drawer shipping today.
  4. Admin unchanged — a caller who may read every field still gets the whole
    snapshot, which is what makes this masking rather than trimming. Modelled as a
    participant on purpose: getRequest enforces participation (审批请求的可见性是「按租户」而非「按参与者」——getRequest 用 SYSTEM_CTX 读、只按 organization_id 收窄 #3590) first, so
    a non-participant "admin" answers null and the pin would have been measuring
    visibility instead of field masking. That was caught by the pin failing.

Plus: the list door narrows (its own assertion); the generic door
narrows (its own assertions); the derived payload_display / payload_labels
maps never carry a restricted key — redaction runs before enrichment, and
those maps are built by walking the snapshot's own keys, so redacting after
would still ship a restricted field's name, its authored label, and the title of
the record it points at; the visibility question is asked as the caller and
about the subject object
, never as SYSTEM_CTX; undefined is not a denial
(#3807) while [] is; and a throwing authority serves unredacted rather than
blanking every drawer.

src vs dist resolution

Three independent arguments, from the files:

  1. All three subject modules are imported relatively (./approval-service.js,
    ./payload-redaction.js, ./payload-redaction-middleware.js); a relative
    specifier resolves to the sibling source and can never reach exports/dist.
  2. The package has no dist/ directory at all — only the dependency closure
    was built ('…^...'), never the package itself, so there is no artifact for a
    subject import to resolve to even in principle.
  3. The only alias in the package's vitest.config.ts is for
    @objectstack/metadata-protocol; it does not touch the subject modules, and
    there is no root vitest config adding others.

Ablation — prediction written before mutating

Two legs, chosen to test the claim that one door's pin does not stand for
another's. Predictions were written to a file first; the tests to redden were
named individually.

Leg Mutation Predicted Observed
A Drop the service-door seam from loadRequest only RED: leak closed + still-served, [] is a denial, keyed on the reading caller (3). GREEN and unmoved: the list-door pin, all generic-door pins, the shape tests Exactly those 3 red, 549 pass; list door and generic door stayed green
B Drop the write-back in redactRowsInPlace only RED: generic read is narrowed, registered against sys_approval_request only (2). GREEN: every service-door pin Exactly those 2 red, 550 pass; every service-door pin stayed green

The prediction also called derived maps are clean as uncertain, predicted
green
— under this engine double there is no metadata registry, so
resolveFieldLabels returns {} and the map is never attached. It is
defence-in-depth here, not a discriminating pin. It behaved as predicted, and is
recorded as such rather than claimed as a bite it does not have.

Restores proved byte-identical:

approval-service.ts             baseline dd38fe823aed762be5307b98577c776f7ebb9436
                                restored dd38fe823aed762be5307b98577c776f7ebb9436
payload-redaction-middleware.ts baseline 66e957c6e8305004f3937ad3b7f1503fe0673daf
                                restored 66e957c6e8305004f3937ad3b7f1503fe0673daf

Breaking or not — argued in the open

A non-admin caller that was reading restricted keys out of the snapshot now
receives fewer keys. That is a real behaviour change for such a consumer, and it
is shipped as a patch fix, not a breaking change, for three reasons stated
here rather than decided silently:

  1. Those fields were never that caller's to read. The platform enforces the
    declaration on every other read door; the approval path was the bypass.
    Removing a bypass is a security fix.
  2. The served type is payload?: unknown — never a promised field set, so no
    declared contract narrows.
  3. Precedent: the __search companion strip (The __search companion column, declared client-invisible, is echoed in every record body #7642) shipped the same way on the
    same reasoning — a column that was never supposed to be served.

The inventory above is what backs claim 1 empirically: no existing consumer
depends on a trimmed field.


Gates

Union derived on the final commit 9ea05bf11, clean tree,
node scripts/pm/dispatch-gates.mjs with no path arguments (6 paths, 136
families discovered). Every exit code captured before any pipe; each verdict
line below is the gate's own.

Gate Verdict
check:engine-double-contract OK — 377 pinned, 133 in the DEBT ledger, 2 exempt.
check:where-matcher 277 matcher(s) discovered, 277 answer the combinator battery correctly or refuse it loudly
check:query-options-erasure 67 unswept non-test site(s) in 17 file(s), none new
check:type-check-coverage OK — 65/78 workspace packages type-checked (plus the root)
check:type-check-debt --re-measure: OK — 33 ledger entr(ies) re-measured in 244.7s, 1908 raw tsc error(s) total, none above its recorded number.
check:i18n OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
check:nul-bytes OK (scanned 6393 text file(s) … no raw ASCII control bytes).
check:test-source-alias OK — 72 packages with tests scanned
check:type-source-resolution OK — 77 packages with a tsconfig.json scanned
check:slot-lookup 107 unswept site(s) in 25 file(s), none new
check-ci-filter-parity OK: all 83 declared cross-package glob(s) … covered
check-plugin-teardown-shape 61 Plugin implementation(s) … baseline fully burned down
check-empty-changeset No empty-frontmatter changeset introduced by this diff
check-changeset-no-major This diff introduces no 'major' bump.
check-adr-0087-registration this PR adds no declared-breaking changeset
check:objectui-changeset, check:changeset-gate-self-tests self-tests pass
check-affected-docs exit 0
Package suite Test Files 29 passed (29) · Tests 552 passed (552)
Package typecheck tsc --noEmit exit 0

Two notes kept honest rather than tidied:

  • check:i18n first returned PREREQUISITE NOT MET — the workspace CLI is not built … Nothing was checked. That is not measured, not a pass; the CLI was
    built and the gate re-run to the real OK above.
  • check:engine-double-contract first went red because the new fake engine
    declared update()/delete(). Rather than growing the pinned ledger, the two
    verbs were removed — this file's subject is the read path and never reaches a
    write verb — so the ledger is untouched and the gate is green on its own terms.

Class #10309: the derivation named check:nul-bytes nowhere; it was run
explicitly on judgment, and is green.


Out of scope, recorded not fixed

Downstream

objectui#5565 (the approval drawer) unblocks in this direction. Nothing in
objectui was touched. Note for that card: the server now omits restricted keys
from payload, and payload_labels / payload_display are already consistent
with what is served, so the drawer can render whatever it receives without a
second visibility rule of its own.

Open question — not decided here

The card's headline also names hidden: true. That half is not implemented,
and deliberately so: hidden is a UI contract ("Hidden from default UI") which,
in the spec's own words, "has never governed serialization". Measured — a
scan for consumers of a field's hidden returns 8 sites (instrument proven on a
known-positive first), and none of them strips a value from a read result:
they are search-companion eligibility, search-field eligibility, two UI layout
helpers, two auto-view column pickers, and two lint rules.

So enforcing hidden on the approval path alone would make approvals stricter
than a direct read of the very same row — closing no leak, since the approver
can simply read the record — while breaking every drawer that renders a hidden
business column. Giving hidden a serialization semantic is a packages/spec
question, which this lane has zero ownership of and which is precisely why
Option C was deferred. Flagged for the contract review rather than decided.

A second, smaller question for the same review: this seam fails open on a
throw
from the visibility authority (logged loudly), so a metadata hiccup
serves unredacted rather than blanking every approval drawer. It does honour the
fail-closed tier getReadableFields itself computes ([]). Both directions are
defensible; the choice is stated rather than buried.


Generated by Claude Code

os-warren and others added 2 commits August 22, 2026 12:35
`sys_approval_request.payload_json` stores the submitted record's raw row,
captured from the flow's `$record` with the record's own FLS never applying.
The column is a `textarea`, so to every read door it is an opaque string and
the subject object's field-level read controls could not reach inside it.

Per the maintainer's 2026-08-22 ruling (Option B) the full snapshot stays at
rest as audit evidence; redaction is applied at serve time, keyed on the
reading caller, using the security service's `getReadableFields` so the seam
cannot drift from data-plane FLS.

Both read doors are covered: the service door (`getRequest` / `listRequests`,
redacting before display enrichment so the derived `payload_display` /
`payload_labels` maps cannot leak a restricted field's name or label), and the
generic data door (object-scoped engine middleware, covering the REST data
routes / ObjectQL / export / MCP family that shares `find` as its producer).

`hidden: true` is deliberately not acted on — it is a UI contract that has
never governed serialization, and giving it one here alone is a spec question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…double

This file's subject is the READ path, which never reaches a write verb.
Declaring update()/delete() would have added an engine double to
`check:engine-double-contract`'s pinned ledger for coverage nothing here
exercises; removing them keeps the ledger untouched and the gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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

What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

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

Which tree this was computed on

This run read content/docs from e8ee3f0aec783e1015211c33839ff0bb080c5817 — the merge of head 9ea05bf1109143f9a54efbc9a7885ed8c6494d2b into base 2866d5f97e9877a438c77ab9b2435a5043ebc0d2, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e8ee3f0aec783e1015211c33839ff0bb080c5817 && git checkout e8ee3f0aec783e1015211c33839ff0bb080c5817
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2866d5f97e9877a438c77ab9b2435a5043ebc0d2 9ea05bf1109143f9a54efbc9a7885ed8c6494d2b && git checkout -B drift-repro 2866d5f97e9877a438c77ab9b2435a5043ebc0d2 && git merge --no-ff 9ea05bf1109143f9a54efbc9a7885ed8c6494d2b

node scripts/docs-audit/affected-docs.mjs --json 2866d5f97e9877a438c77ab9b2435a5043ebc0d2

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

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 22, 2026
@os-warren
os-warren marked this pull request as ready for review August 22, 2026 15:30
@os-warren
os-warren added this pull request to the merge queue Aug 22, 2026
Merged via the queue into main with commit 07bd1ca Aug 22, 2026
35 checks passed
@os-warren
os-warren deleted the claude/issue-10749-approval-payload-read-masking branch August 22, 2026 15:47
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32581943688 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 5.80s
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️ 本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 83 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…FOR (objectstack-ai#11837)

`sys_approval_request`'s module docstring justified `payload_json` with "used
by notifications so they can render before the record is locked or changed".
That consumer does not exist. Measured against every `this.notify(...)` call
site in `approval-service.ts` -- all 12 -- each passes `{ title, message,
actionUrl }` (two also `actions`), built from `object_name` / `record_id` and
the caller's comment. None reads `payload_json` or the parsed `payload`.

The sentence was the only documented justification for the column holding a
FULL row, and it was cited as such during the objectstack-ai#10749 consumer inventory before
anyone checked it. The docstring now states the real reason: the snapshot is
retained as audit evidence of what was actually submitted (so the column stays
whole at rest) and is served redacted per reader by the subject object's
field-level read controls via `getReadableFields`, on the approvals-inbox door
and the generic data door alike (objectstack-ai#11039).

The field's own `description` is deliberately unchanged -- it is accurate, and
it, not the JSDoc, is the string extracted into the four generated i18n
bundles. Proven rather than assumed: regenerating with the documented command
left all four bundles byte-identical, and a positive control (mutating the
extracted `description`) moved the `en` leaf, so the zero is a real zero.

Also carries the residual documentation objectstack-ai#10749's closure assigned to the next
docs touch in this lane: `payload-redaction.ts` recorded hidden-vs-serialization
as an OPEN `packages/spec` question, and it has since been ruled (maintainer,
2026-08-24). That paragraph now states the ruling -- `hidden: true` stays
UI-only; `internal: true` is the serialization primitive -- and points an
author who needs a field kept out of read results at `internal` (objectstack-ai#7728,
ADR-0049) rather than at `hidden`, which never governed serialization.

Documentation only: no runtime behaviour, schema field, or type signature
changes. The corrected JSDoc does ship to consumers in `dist/index.d.ts`,
which is why this carries a patch changeset rather than none.

Part of objectstack-ai#11041


Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant