Repository navigation
fix(approvals): apply the subject object's field-level read controls to the payload snapshot at serve time - #11039
Conversation
`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
📓 Docs Drift CheckThis PR changes 1 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32581943688 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…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>
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-reviewon the card: what a published read returns toa non-admin changes. It is a narrowing — nothing here widens what any caller
receives.
Mechanism
sys_approval_request.payload_jsonstores the submitted record's raw row,captured from the flow's
$recordvariable, which the automation layer handsover with the record's own FLS never applying. The column is a
textareaonsys_approval_request, so to every read door it is an opaque string: thefield-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 fieldnon-readable, a
maskingRule— was therefore unenforceable on the approvalpath, 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.
getReadableFieldsanswers a caller with no field-permission entries with thefull 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_jsonA repo-wide scan for
payload_jsonreturns 53 hits, and that is not aconsumer count: the identifier is a field name on three unrelated objects —
sys_job_queue(service-queue),http_delivery(service-messaging) andsys_approval_request. Classified individually, hits on the approval object'scolumn are these, and only these:
approval-service.ts:415(rowFromRequest)payloadapproval-service.ts:2267expandApprovers)rawcomes fromloadPendingRow, aSYSTEM_CTXread; theisSystemskip leaves it wholeapproval-service.ts:4096{ payload_json: { $contains: q } }sys-approval-request.object.tsrecord-reader-visibility.test.ts,approval-resume-relation-expand.test.tsConsumers the ruling asked about specifically, each checked rather than assumed:
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 alsoactions). None reads thesnapshot. 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.renderConfirmPage/renderResultPagerendersummaryRows, which readsrecord_title,object_label,submitter_name,record_id,object_name. Not a payloaddoor.
flow_run_id/flow_node_idand readsnode_config_json, notpayload_json.packages/,apps/andexamples/forsys_approval_requestreturns 16 non-test filesoutside
plugin-approvals; none of them appears in thepayload_jsonscan — 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 asevidence.
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.
GET /api/v1/approvals/requestsand…/:id→listRequests/getRequest→rowFromRequest→ the parsedpayload(pluspayload_display/payload_labels)enable.apiMethods: ['get','list'], so a plainfind/findOnereturns 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:findon the engineSYSTEM_CTX; the audit channelDoor 2 is registered as object-scoped engine middleware, not an
afterFindhook, and that choice is load-bearing: a hook receives
Engine.buildSession'soutput, which carries no
onBehalfOf.getReadableFieldsintersects thedelegator'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 narrowidentically.
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:
salary,ssn,internal_notes. Asserted by name; a shape assertion would pass againsta redactor that removed the wrong keys.
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.
id/title/amount/submitted_by. A seam that redacted everything would also score green on aleak-only suite and break every drawer shipping today.
snapshot, which is what makes this masking rather than trimming. Modelled as a
participant on purpose:
getRequestenforces participation (审批请求的可见性是「按租户」而非「按参与者」——getRequest 用 SYSTEM_CTX 读、只按 organization_id 收窄 #3590) first, soa non-participant "admin" answers
nulland the pin would have been measuringvisibility 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_labelsmaps 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;undefinedis not a denial(#3807) while
[]is; and a throwing authority serves unredacted rather thanblanking every drawer.
srcvsdistresolutionThree independent arguments, from the files:
./approval-service.js,./payload-redaction.js,./payload-redaction-middleware.js); a relativespecifier resolves to the sibling source and can never reach
exports/dist.dist/directory at all — only the dependency closurewas built (
'…^...'), never the package itself, so there is no artifact for asubject import to resolve to even in principle.
vitest.config.tsis for@objectstack/metadata-protocol; it does not touch the subject modules, andthere 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.
loadRequestonly[]is a denial, keyed on the reading caller (3). GREEN and unmoved: the list-door pin, all generic-door pins, the shape testsredactRowsInPlaceonlysys_approval_requestonly (2). GREEN: every service-door pinThe prediction also called derived maps are clean as uncertain, predicted
green — under this engine double there is no metadata registry, so
resolveFieldLabelsreturns{}and the map is never attached. It isdefence-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:
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:
declaration on every other read door; the approval path was the bypass.
Removing a bypass is a security fix.
payload?: unknown— never a promised field set, so nodeclared contract narrows.
__searchcompanion strip (The__searchcompanion column, declared client-invisible, is echoed in every record body #7642) shipped the same way on thesame 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.mjswith no path arguments (6 paths, 136families discovered). Every exit code captured before any pipe; each verdict
line below is the gate's own.
check:engine-double-contractOK — 377 pinned, 133 in the DEBT ledger, 2 exempt.check:where-matcher277 matcher(s) discovered, 277 answer the combinator battery correctly or refuse it loudlycheck:query-options-erasure67 unswept non-test site(s) in 17 file(s), none newcheck:type-check-coverageOK — 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:i18nOK (9 package(s) — all bundles in sync, no undeclared authoring keys).check:nul-bytesOK (scanned 6393 text file(s) … no raw ASCII control bytes).check:test-source-aliasOK — 72 packages with tests scannedcheck:type-source-resolutionOK — 77 packages with a tsconfig.json scannedcheck:slot-lookup107 unswept site(s) in 25 file(s), none newcheck-ci-filter-parityOK: all 83 declared cross-package glob(s) … coveredcheck-plugin-teardown-shape61 Plugin implementation(s) … baseline fully burned downcheck-empty-changesetNo empty-frontmatter changeset introduced by this diffcheck-changeset-no-majorThis diff introduces no 'major' bump.check-adr-0087-registrationthis PR adds no declared-breaking changesetcheck:objectui-changeset,check:changeset-gate-self-testscheck-affected-docsTest Files 29 passed (29) · Tests 552 passed (552)tsc --noEmitexit 0Two notes kept honest rather than tidied:
check:i18nfirst returnedPREREQUISITE NOT MET — the workspace CLI is not built … Nothing was checked. That is not measured, not a pass; the CLI wasbuilt and the gate re-run to the real
OKabove.check:engine-double-contractfirst went red because the new fake enginedeclared
update()/delete(). Rather than growing the pinned ledger, the twoverbs 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-bytesnowhere; it was runexplicitly on judgment, and is green.
Out of scope, recorded not fixed
listRequests' free-text filter pushespayload_json: { $contains: q }into the engine — a probe oracle over snapshot contents the caller may not read #11040.listRequests' free-text filter pushes{ payload_json: { $contains: q } }into the engine, so a caller can stillprobe snapshot contents they cannot see — the same oracle shape
maskingRulecloses by making masked fields non-filterable. Untouched herebecause it is a filter, not a serve door, and closing it changes search
semantics.
sys_approval_request.payload_json's doc comment claims notifications render from the snapshot — measured false against all 12 notify call sites #11041. The column's docstring claimsnotifications render from the snapshot; measured false against all 12 notify
call sites. Left alone here because the string is extracted into generated
i18n bundles, so rewording it is a regeneration change rather than a comment
edit.
Downstream
objectui#5565 (the approval drawer) unblocks in this direction. Nothing in
objectuiwas touched. Note for that card: the server now omits restricted keysfrom
payload, andpayload_labels/payload_displayare already consistentwith 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:
hiddenis 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
hiddenreturns 8 sites (instrument proven on aknown-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
hiddenon the approval path alone would make approvals stricterthan 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
hiddenbusiness column. Giving
hiddena serialization semantic is apackages/specquestion, 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
getReadableFieldsitself computes ([]). Both directions aredefensible; the choice is stated rather than buried.
Generated by Claude Code