fix(plugin-security): tell a read that did not answer from one that answered nothing, per site (#15840) - #17156
Conversation
…nswered nothing, per site (#15840) `reconcileOrgAdminGrant`'s `sys_member` read swallowed a fault into `[]`, and `[]` is what that function reads as "this user is not an admin of this organization" — the input to a DELETE. A transient read fault therefore revoked a sitting admin's standing grant and the store kept it withdrawn after the fault cleared; only a `debug` line separated that run from a healthy one. That read now reports at `error` and returns `{ action: 'skipped', reason: 'membership_unreadable' }`, performing no write at all for the pair. `normalizeManagedByVocab` swallowed a catalog read fault into `[]` too, so an unreadable catalog and an already-canonical one were byte-identical on both channels while the row that needed healing stayed legacy. It now reports at `error` and refuses the pass rather than attesting counts it could not read. The refusal aborts at the first un-answered read — one line per refused boot, not the four the report-and-continue shape measured — and reaches the `kernel:ready` catch that was already declared for it. Per-site, not a sweep (#15840 ruling, decision batch #105 item 5, option A). A genuine EMPTY read keeps today's behaviour exactly at both seams, pinned by positive controls. `claim-seed-ownership.ts` is untouched — zero diff: its fault already propagates to a per-predicate handler that reports at `warn` and names the consequence, so the card's third row is falsified. The plugin's other reads keep their best-effort contract, where an unanswered read costs a grant that is not created rather than one that is destroyed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 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 72339ffc8d4f418708179e1bffdc43c1a831e37e && git checkout 72339ffc8d4f418708179e1bffdc43c1a831e37e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin df8a16deeee88bc0609f0230263d703d0fb885a9 4f096ae89d55bbe961cef158b319e4771943c871 && git checkout -B drift-repro df8a16deeee88bc0609f0230263d703d0fb885a9 && git merge --no-ff 4f096ae89d55bbe961cef158b319e4771943c871
node scripts/docs-audit/affected-docs.mjs --json df8a16deeee88bc0609f0230263d703d0fb885a9
|
…ity at both new report sites (#15840) `Logger.error` (packages/spec/src/contracts/logger.ts) takes the `Error` in its OWN second argument — `(message, error?, meta?)` — unlike `info`/`warn`/`debug`, which take `(message, meta?)`. Both new optional `error?` members were declared with the sibling two-parameter shape, which made the real `ctx.logger` unassignable: measured as three TS2322s in `security-plugin.ts`, at :3818, :3911 and :3937. Fixed at the declaration rather than by widening the consumer: the producer's spelling is the contract. Both call sites now hand the caught `Error` to the argument built for it, so a platform logger receives a real Error object rather than a message flattened into meta. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…ad-seam-fault-vs-empty
…ad seam adds (#15840) `readRows`'s `context: { isSystem: true; tenantId?: string }` parameter is a 23rd `isSystem` declaration site, so `check:system-context-census` went red on its `[declared-count]` clause (table says 22, census says 23). Mechanical repair, applied by the gate's own `pnpm gen:system-context-census --fix`; one number in the decomposition table, no prose and no anchors touched. Attribution was MEASURED, not assumed — a first reading of the census JSON suggested the site was not mine. Reverting only this branch's three source files on top of the same merged tree gives declaration 22 and gate exit 0; restoring them gives 23 and exit 1. It is mine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
… `error` is optional (#9754) `check:optional-error-sink` went red the moment this branch added an `error?` member: that put `MaybeLogger` into the gate's population for the first time, and it declared every channel optional. An optional `error` with no declared alternative is a contract that permits silence — a value of this type could carry no channel at all, and the durability report this module owes (a standing capability withdrawn, or a revoke that did not land) would have nowhere to go. The gate's own remedy, taken exactly: `warn` loses its `?`. ⛔ Not by making `error` REQUIRED (#9754 option C, falsified — hosts inject reduced sinks), and ⛔ not by requiring `info`, which is the level AGENTS.md → "Degradation log levels" calls the reassuring half-truth. Narrowing the type forced NO call-site change: every existing caller already supplied `warn`, measured by tsc over both layers (typecheck exit 0, and check:test-typecheck 0 files / 0 errors), plus 105 files / 1977 tests green. The sibling `normalize-managed-by.ts` sink was checked rather than assumed: its `info`/`warn` were already non-optional, so it sits in the gate's compliant set and is not named. Census moves 33 -> 34 sinks with a guaranteed `warn`, and "permit silence" 2 -> 1, the remaining one being the pre-existing baseline entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Fixes #15840
Clause-②: no
Executes the ruling recorded at issue-15840#issuecomment-5595728039 — director seat, decision batch #105 item 5, option A: per-site separation of "read failed" from "read empty" at the two live seams. Not re-litigated; the options block earlier in the thread is superseded.
The p1 this closes
reconcileOrgAdminGrant'ssys_memberread went throughtryFind, which mapped a thrown read onto[].[]is exactly what that function reads as "this user is not an admin of this organization" —shouldGrant === false— which is the input to the revoke branch, which deletes a standing grant. So a transient storage fault withdrew a sitting admin's org-admin capability, the store kept it withdrawn after the fault cleared, and only adebugline separated that run from a healthy one.{action: 'noop'}. It does not — that value belongs to a different leg, the one report5553806224measured, where the grant-table read faults and the revoke is missed. On the ruled leg the verdict is{action: 'revoked'}and a row is deleted. Both readings are in the thread; the ruling's own pointer:602 → :713 ff.names the wrongful-revoke leg, which is the one graded p1 by the regrade at5552131360("this one writes"), and the one fixed here. Measured red-then-green below.Per site — ⛔ not a uniform sweep
auto-org-admin-grant.ts— thesys_memberread feeding the revoke brancherrorand returns{action: 'skipped', reason: 'membership_unreadable'}. The revoke branch is never entered — asserted on thedeletecall count, not the outcome. A genuine empty read is byte-identical to today.normalize-managed-by.ts:53error. ⛔ It never answers "already canonical". A genuine empty read is byte-identical to today.claim-seed-ownership.ts:177git diff HEAD -- packages/plugins/plugin-security/src/claim-seed-ownership.tsis 0 bytes. The card's third row is falsified; its three inherited pins are kept verbatim as the proof.tryFindkeeps its a best-effort array-of-rows best-effort contract for every other read in the module. The fault is now available through a module-privatereadRows, and exactly one caller — the one that turns the value into a DELETE — asks for it. That is what makes this A and not C.⭐ The positive controls, which are half of the fix
Without these the change is indistinguishable from "make everything refuse", i.e. option C — the one the maintainer refused. All pass:
sys_memberread still revokes ({action:'revoked'}, exactly onedeleteofsys_user_permission_set, zeroerror:lines);{action:'granted'}, zeroerror:lines);{positions: 0, permissionSets: 0}with zero lines at any level;find/updateis not a read fault and still returns zeros;noopatdebug, and a faulted backfill sweep read still reports "backfill complete" atinfo. Both are missed revokes, not the wrongful one; see acceptance notes.One stated consequence, pinned rather than left to be discovered
The ruling's disposition is "skips that user for the round" and "never enters the revoke branch", so the early return lands before the membership-independent superseded-revoke leg (1b). A round that could not read performs no write at all. Consequence: on a faulted round a superseded-variant grant lingers one round longer than it does today. It is pinned by its own test with a healthy control beside it. ⛔ It is not a relaxation of any boundary — nothing is granted, so no population widens; today's alternative on that same round is a wrongful revoke, which is not a baseline worth preserving; and the next round removes it. If a reviewer prefers the leg to run anyway, the change is the placement of one early return.
Surface — the no-widening declaration above, honoured
⛔ No exported symbol added, no key on a published payload, no
packages/spec/src/**path, noerror-code-ledger.zod.tsrow.action: 'skipped'is already in the returned union;reasonis alreadystring, so'membership_unreadable'is a value, not a widening. (Report5553806224measuredreasonhas zero non-test readers — nothing breaks on a new value.)MaybeLoggerandNormalizeOptions['logger']are not exported; each gains an optionalerror?method — an input the module asks for, not a channel it publishes. Required by the ruling's own "reports aterror". Every caller that compiles today still compiles.Error— no new class, nocode, so no ledger row.action: 'skipped'already existed at six pre-existing sites (:614, :617, :653, :749, :793, :846), so no member was added to that closed union;reasonis already an open string vocabulary (objectql_unavailable,missing_keys,permission_set_missing,insert_failed,delete_failed), somembership_unreadableis a seventh value in it and not a new key. The mechanical floor is a new key or a new exported symbol — neither happened. (check-widening-tells --declaration noexits 0 on this diff, but that is silence rather than corroboration: the tool's declared surfaces do not coverpackages/plugins/**, [finding] check-widening-tells counts a file it structurally cannot judge as "read" — so exit 0 reads as corroboration for every published surface outside packages/spec #17112. Thenorests on the key/symbol reading, not on the tool.){ records }-normalizer gate's population is app-showcase page modules, but all three instances found so far are outside it — #14460's stated evidence threshold is now met #15094). ⛔ Nothing relaxed: enforcement is unchanged in both directions, and themanaged_bywrite gate recognises both vocabularies, so rows left un-normalised by a refusal are still gated.Changeset —
patch, argued.changeset/plugin-security-read-fault-vs-empty.md. Same family and a strictly smaller blast radius than the sibling repair it descends from: PR #15838 made this exact fault-vs-empty change on the enforcement plane (SecurityPlugin'ssys_permission_setloader, now refusing withDATABASE_ERROR) and shipped it aspatch. These two are boot reconcilers, no type signature moves, and healthy-path behaviour is byte-identical — only the fault path changes. ⛔ Analysis does not saymajor/protocol:breaking.Verification — union run at
411ac8d16, exit codes captured before any pipepnpm --filter @objectstack/plugin-security exec vitest run→ 105 files / 1977 tests passed, exit 0.pnpm --filter @objectstack/plugin-security run typecheck→ exit 0;check:test-typecheckOK, 0 files / 0 errors. NOT-MEASURED trap closed:tsc -p tsconfig.test.json --listFilesshows all three edited files in the program.eslint . --no-inline-config --format json→ 6433 files, 0 errors, 0 warnings, exit 0. Full population, so no narrowing to declare.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon the final tree derives 90 families; 90 run, 0 UNRUN, reconciled with--ran. 87 exit 0. 3 exit 3 =PREREQUISITE NOT MET= NOT MEASURED, never a pass —check:dual-build-cjs-loads,check:i18n,check:type-check-debt, all three blocked on a whole-repo build this worktree does not carry.check:type-check-debtis not the The 4096 heavy-command prefix in os-dev.md makes check:type-check-debt OOM into exit 3 — the gate pins its own 6144 CI-shaped ceiling #17151 OOM: run at--max-old-space-size=6144it cleared the heap and stopped on the built-closure prerequisite instead.check:docs,check:skill-examples) first refused on build prerequisites and were re-run green after building@objectstack/specand@objectstack/client-react.Red-then-green ablation — the pins bite
Both source files reverted to their pre-fix content on top of the same tree, mutated and measured in one shell (the inherited warning: a helper carrying its own
EXITtrap restores before the measurement and produces a confident, wrong green).1cea5386…→49b0ac3d…and9a6baa7a…→fa327193…, with anchors movingmembership_unreadable2→0,readRows4→0,findOrRefuse2→0.[RULED]cases.sys_memberread faulted, the pre-fix tree issueddelete sys_user_permission_set where id = ups_standing— the standing grant of a sitting admin, deleted by a transient read fault. The call-count assertion is what catches it; the outcome alone reads as an ordinary{action:'revoked'}.1cea5386…/9a6baa7a…and a 0-bytegit diff HEAD— never by an exit code.Post-review:
check:optional-error-sink(#9754)Adding the
error?member the ruling asks for putMaybeLoggerinto that gate's population for the first time, and it declared every channel optional — a contract that permits silence. Repaired with the gate's own stated remedy:warnloses its?. ⛔ Not by makingerrorREQUIRED (#9754 option C, falsified — hosts inject reduced sinks), ⛔ not by requiringinfo. Gate now exit 0; census moves 33 → 34 sinks with a guaranteedwarnand "permit silence" 2 → 1, the remaining one being the pre-existing baseline entry. The siblingnormalize-managed-by.tssink was checked rather than assumed — itsinfo/warnwere already non-optional, so the gate never named it. Narrowing the type forced no call-site change: every existing caller already suppliedwarn, measured by tsc over both layers (typecheck exit 0,check:test-typecheck0 files / 0 errors) with 105 files / 1977 tests still green.Three follow-on commits worth reading
MaybeLogger.warnmade non-optional — see the section directly above.Logger.errorarity. The canonicalLoggercontract (packages/spec/src/contracts/logger.ts) takes theErrorin its own second argument —(message, error?, meta?)— unlikeinfo/warn/debug. Declaring the sibling two-parameter shape made the realctx.loggerunassignable: three TS2322s atsecurity-plugin.ts:3818, :3911, :3937. Fixed at the declaration, contract-first, rather than by widening the consumer.check:system-context-censusre-tally.readRows'scontext: { isSystem: true; tenantId?: string }is a 23rdisSystemdeclaration site, so the gate's[declared-count]clause went red. Repaired mechanically by the gate's ownpnpm gen:system-context-census --fix— one number incontent/docs/permissions/system-context.mdx, no prose, no anchors.gen:schemarewrote nothing tracked.验收备注 (noted, not filed)
sys_user_permission_setread (:723) and a faultedresolvePermissionSetIdsForNameread both yield[]→{action:'noop'}, so a capability the platform decided to withdraw stays in force. That is the opposite direction from the p1 fixed here (a missed revoke, not a wrongful one), it is already measured and pinned by PR test(plugin-security): three read-fault readings for the seams #15598 fenced out — measurement only, no behaviour change #15998's cases which this PR deliberately leaves green, and the ruling names neither. Observation, not filed: it is documented in this card's own thread and filing it separately would duplicate that record.claim-seed-ownership.ts's non-array arm warns "the predicate matched no rows to page" while the envelope was in fact carrying the row — reported, but attributing a cause that did not happen. Unreached on the shipped engine (plugin-security: 7{ records }union-normalizer blocks onfind()results — dead limbs, one of them also #15092's DROP shape (security-plugin.ts:1292) #15598), so trap-removal, not a live defect. The ruling says zero diff at this file; not touched.read-fault-distinguishability.test.ts, a realObjectQLover a realSqlDriverwith a one-verbProxy) is onmainand was reused rather than rebuilt. Only the branch is gone.security-plugin.ts/rls-compiler.tsand fix(plugin-security): stop letting org-admin row count decide whether a platform admin already exists #17116 onbootstrap-platform-admin.ts. This PR touches none of them — no conflict..objectui-shauntouched.Generated by Claude Code