Skip to content

fix(approvals): stop pushing a free-text predicate onto a masked payload snapshot - #11103

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-11040-approvals-free-text-probe
Aug 22, 2026
Merged

fix(approvals): stop pushing a free-text predicate onto a masked payload snapshot#11103
os-warren merged 2 commits into
mainfrom
claude/issue-11040-approvals-free-text-probe

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #11040

What was wrong

sys_approval_request.payload_json holds the submitted record's row. Since #10749 the serve path masks it per readerredactPayloads cuts each row's snapshot down to the fields that caller may read on that row's subject object — while the full row deliberately stays at rest, so the approval record remains audit evidence of what was actually submitted.

listRequests / countRequests pushed a free-text disjunct onto that same column. A filter is evaluated by the driver against the column at rest, before anything is served, so the field-level read controls #10749 enforces on the way out did not hold on the way in: the snapshot column was a free-text predicate for callers who do not read it whole. That is declared without enforced, and the platform has already settled the governing principle for it — under maskingRule (#8993) a field a caller sees masked is non-filterable, refused loudly, because otherwise equality probes reconstruct the hidden span. This PR extends that settled posture to the snapshot column, reached through a different door.

What changed

buildRequestWhere now decides the fifth disjunct per caller. The four columns of sys_approval_request itself — process_name, object_name, record_id, submitter_id — are read whole by anyone who can see the row and are untouched; they are always present, so the $or is never empty (an empty $or reads as match-none on some drivers and match-all on others, which would be a silent second defect). The snapshot disjunct is added only when the serve path would hand this caller the whole snapshot.

Dropping a disjunct is strictly narrowing: no query is refused, nothing is widened, and ordering and pagination are untouched. Refusing the whole query would have been the louder behaviour change, and is deliberately not what this does.

The invariant that shapes the implementation

The masked/unmasked verdict is read from the same authority and the same per-caller call the serve path uses (resolveReadableSnapshotFields), asked as the caller, never as SYSTEM_CTX. It is deliberately not a second, independently derived notion of "redacted" — comparing the readable set against the object's schema, say. Two derivations drift, and drift between a serve rule and a filter rule is precisely the defect being closed, reconstituted one layer down. So the only "not masked" answer accepted is the one serve itself acts on: undefined, the seam's documented do-not-narrow branch. When the seam holds a concrete list the mask is in force, whether or not it happens to remove a key from any particular row — a row-dependent question no predicate can answer before rows exist.

The predicate-time tension, and how each case is handled

Redaction is decided per row (each row names its own subject object, hence its own readable set), but a filter is built before any row exists. Both cases are handled explicitly:

scope at predicate time handling
authority absent resolveReadableSnapshotFields answers undefined for every object, so serve hands over every snapshot whole. The disjunct is kept — it discloses nothing serve does not already disclose. This is the shape every deployment that has not wired the security plugin runs: search is byte-for-byte unchanged. Checked first, and that order is load-bearing: the same helper also answers undefined for a blank object name, and reading that as "not masked" would hand the unfiltered query a disjunct on exactly the grounds that make it unanswerable.
authority wired, filter.object present the subject object is known at predicate time; the seam is asked about that object directly, as the caller. undefined keeps the disjunct, a concrete list drops it.
authority wired, no filter.object the query spans every object and there is nothing sound to ask, so the disjunct is dropped.

Consistency with serve is the rule here rather than blanket fail-closed: hardening the undefined case into a narrowing serve itself does not perform would invent a behaviour with no counterpart on the way out.

Accepted consequence, stated plainly: in a deployment with a field-visibility authority wired, the disjunct is dropped for every caller the mask is in force on — including one whose mask happens to be total for a given row. Narrowing that case is strictly safe, and the alternative (deciding "the mask is a no-op for this caller" from the object schema) is the forbidden second derivation above.

The pin

packages/plugins/plugin-approvals/src/approval-free-text-scope.test.ts — 13 tests in four groups, each of which a plausible wrong implementation needs:

  1. narrowed — a masked caller's free-text search no longer matches on snapshot contents; object-filtered scope, unfiltered scope, countRequests agreeing with the list it paginates, and the security plugin's own fail-closed tier.
  2. still searches — the same masked caller still matches on the four columns they read whole, and the $or carries exactly four arms. Without this, dropping free-text search altogether scores green on (1) alone.
  3. unmasked unchanged — same rows, same order, for both shapes in which serve declines to narrow: no authority wired, and a wired authority answering undefined (approvals: a department approver never resolves when the business unit has organization_id = null (every seeded BU) #3807, explicitly not a denial). Without this, "drop the arm for everyone" scores green on (1) and (2).
  4. same authority as serve — the seam is asked as the caller, about the subject object, and not at all when there is no free-text term to scope.

Ablation, both legs, at d0d9ef99c. The test imports ./approval-service.js — a relative specifier inside the package, so vitest reads src/ directly and neither leg involves dist/; no rebuild can hide a mutation here.

  • mutation (guard removed, snapshot disjunct restored unconditionally): 6 failed | 7 passed — every assertion in groups (1) and (2) reddens. Predicted direction before running: redden.
  • restore (git checkout from the commit, mutation marker confirmed absent, working tree byte-identical to HEAD): 13 passed.

The 7 that stay green under the mutation are exactly groups (3) and (4) — behaviour the mutation does not change — which is what makes the red set attributable rather than a blanket failure.

Verification, at d0d9ef99c

pnpm --filter @objectstack/plugin-approvals test30 files, 565 tests passed. typecheck — clean (tsc --noEmit, script name echoed, not a zero-match no-op).

Derived gate union via node scripts/pm/dispatch-gates.mjs (no paths passed — the script derives the change set from the merge base itself); 17 families plus the ratchet, each exit code captured before any pipe, all green.

One ratchet reddened during the work and was fixed in the code, never in the baseline: check:type-check-coverage --re-measure reported @objectstack/plugin-approvals TEST_DEBT 348 -> 349. The package's own typecheck excludes **/*.test.ts, so the error was invisible there. Cause: Array.prototype.at against this package's lib: ES2021. Indexed from the end by hand instead; the count is back to the frozen 348 with zero errors naming the new file, and the gate's own verdict line now reads --re-measure: OK — 33 ledger entr(ies) re-measured, 1908 raw tsc error(s) total, none above its recorded number. The ledger was not touched and lib was not widened.

check:i18n first returned PREREQUISITE NOT MET — the workspace CLI is not built / Nothing was checked. That is not measured, not a pass; it was re-run after building the workspace closure and then reported OK (9 package(s) — all bundles in sync), naming plugins/plugin-approvals in sync (4 bundle(s)).

Release notes

Input is the changeset, .changeset/approvals-free-text-not-over-masked-snapshot.md, which states this as a deliberate search-semantics change. No content/docs/releases/ file is touched.


Generated by Claude Code

claude added 2 commits August 22, 2026 16:20
…oad snapshot

The approval payload snapshot is redacted at serve time, per reader (#10749),
but `listRequests` / `countRequests` pushed a free-text disjunct onto the stored
column, which the driver evaluates unmasked. The field-level read controls
enforced on the way out therefore did not hold on the way in — declared, not
enforced. This extends the settled `maskingRule` posture (#8993) to the snapshot
column: a caller whose view of the snapshot is masked keeps free-text matching
on the four columns of `sys_approval_request` they read whole, and loses only
the snapshot disjunct.

The masked/unmasked verdict comes from the same authority and the same
per-caller call the serve path uses, asked as the caller — never a second,
independently derived notion of "redacted", since that drift is the defect one
layer down. Where serve declines to narrow (no authority wired, or a wired
authority answering "unresolvable"), the disjunct is kept: consistency with
serve, not blanket fail-closed.

Dropping a disjunct is strictly narrowing — no query is refused, nothing is
widened — and four disjuncts always remain, so the `$or` is never empty.

Part of #11040
…type.at`

`lib` is ES2021 for this package, where `at` does not exist. The package's own
`typecheck` excludes `**/*.test.ts`, so the error surfaced only in the
`check:type-check-coverage --re-measure` ratchet, as TEST_DEBT 348 -> 349.

Fixed in the test rather than by growing the ledger or widening `lib`: the
ratchet is shrink-only, and the file needs no ES2022 library surface.

Part of #11040
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/approvals.mdx (via listRequests (symbol), /api/v1/approvals/requests (route))
  • content/docs/automation/flows.mdx (via ApprovalService (symbol), /api/v1/approvals/requests (route))
  • content/docs/kernel/services-checklist.mdx (via /api/v1/approvals/requests (route))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via listRequests (symbol))
  • content/docs/releases/v17.mdx (via countRequests (symbol), listRequests (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 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 ab47f6974a275586355f06abd83a0975b8637d12packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3c1b4cfb6ac005dd857d54e8b0eeec67d573604b — the merge of head d0d9ef99c1b3e6f652195567d5a7dde3ccb7553d into base ab47f6974a275586355f06abd83a0975b8637d12, 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 3c1b4cfb6ac005dd857d54e8b0eeec67d573604b && git checkout 3c1b4cfb6ac005dd857d54e8b0eeec67d573604b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ab47f6974a275586355f06abd83a0975b8637d12 d0d9ef99c1b3e6f652195567d5a7dde3ccb7553d && git checkout -B drift-repro ab47f6974a275586355f06abd83a0975b8637d12 && git merge --no-ff d0d9ef99c1b3e6f652195567d5a7dde3ccb7553d

node scripts/docs-audit/affected-docs.mjs --json ab47f6974a275586355f06abd83a0975b8637d12

⚠️ 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 ab47f6974a275586355f06abd83a0975b8637d12 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

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

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

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

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

    ✗ Build failed in 4.81s
    

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

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

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

历史信号:

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

分诊清单:

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

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

Merged via the queue into main with commit f0d7647 Aug 22, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-11040-approvals-free-text-probe branch August 22, 2026 17:29
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

2 participants