fix(plugin-security): propagate engine faults from readRowById instead of flattening to null - #7697
Conversation
…d of flattening to null
`readRowById` answered `null` for three different facts — the row does not
exist, the engine threw, and no engine is wired — and every gate that probes
with it read all three as "no such row". Its own contract note claimed a `null`
"always DENIES downstream"; that was true of one caller and false of the rest,
in two opposite directions:
- `assertControlledByParentWrite` reported a store outage as 404
RECORD_NOT_FOUND — terminal to an SDK, at the moment the truthful answer
was a transient fault to back off on (the leg #7474 made explicit);
- the two admin-door provenance gates (ADR-0086 two-doors, ADR-0066 asset
ownership) read `null` as "not package/platform-managed" and let the write
THROUGH — fail-OPEN for the duration of the fault, on both the by-id and
the bulk-filter branch;
- the owner-anchor echo caught the throw and answered 403 "changing record
ownership": fail-closed, but accusing the caller of something they did not
do, on an envelope a client will not retry.
Per the maintainer ruling of 2026-08-11 on #7505 the posture is fail-closed and
an outage is never a missing record. An engine fault now propagates out of the
probe and out of the gate: the write is refused before `next()`, and the error
is re-thrown exactly as the engine threw it rather than re-badged, so
objectql's DatasourceUnavailableError keeps ERR_DATASOURCE_UNAVAILABLE and
reaches the wire as 503. Wrapping it in a security code would relabel a
dependency outage as an authorization event and register a second spelling of
an existing ADR-0112 ledger entry under a package that does not own it.
`null` from the probe now means one thing: the row is genuinely absent.
Deliberately unchanged: the master-visibility probe in the same gate still
treats a throw as "not visible" and answers 403. The two probes ask different
questions — "does this row exist", which an outage leaves unanswered and which
must not be answered "no", versus "is this master visible to you under your own
write policy", whose fail-closed default genuinely is "not visible". The issue
and the ruling both name that probe as the house posture to match.
Steady-state behaviour is unchanged at every call site; only the fault path
moved. Both directions are pinned per caller, and reverse-verified: reverting
the four behaviour changes turns exactly the eight new fault-path cases red and
leaves all 36 steady-state cases green.
Fixes #7505
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd
…drowbyid-fail-closed
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #7505
What was wrong
SecurityPlugin's shared by-id probe,readRowById, answerednullfor three different facts — the row does not exist, the engine threw (driver down, table missing, timeout), and no engine is wired — and every gate that probes with it read all three as "no such row". Its own contract note claimed anull"always DENIES downstream". That was true of one caller and false of the other three, in two opposite directions:assertControlledByParentWriteRECORD_NOT_FOUNDfor an absent rowassertPackageManagedWriteGate(ADR-0086)assertSystemRowWriteGate(ADR-0066)getCallerPreImage(owner-anchor echo)The two fail-open cases had no middleware-level coverage at all, which is why they went unmeasured. Both provenance gates leaked the same way on their bulk-filter branch too, one
ifabove the by-id one, via a sibling.catch(() => null).The ruling this implements
Maintainer ruling recorded on the issue 2026-08-11T08:08Z:
What changed
An engine fault now propagates out of the probe and out of the gate. The write is still refused — the throw happens before
next(), so nothing reaches the driver — and the error is re-thrown exactly as the engine threw it rather than re-badged. objectql'sDatasourceUnavailableErrorkeeps itsERR_DATASOURCE_UNAVAILABLEcode andmapDataErrorturns that into 503, which is the answer a client can act on.Re-badging was considered and rejected on two grounds: it would relabel a dependency outage as an authorization event, and it would register a second spelling of an existing ADR-0112 ledger entry under a package that does not own it.
plugin-securityis not the producer of this condition and has nothing to add to it — so this PR adds no new error class, no new code, and no ledger entry.nullfrom the probe now means one thing: the row is genuinely absent.Four behaviour changes, all inside
readRowByIdand its direct callers:readRowByIdno longer catches. The third collapsed fact — no engine wired — throws rather than reading as absence; it is unreachable in a real deployment, becausestart()registers no security middleware at all without a query engine.catch { unchanged = false }is removed. It predates the probe distinguishing the two facts: with both arriving asnullit was the only fail-closed answer available. The oracle it protected is untouched — an absent row and a row the caller cannot read both still returnnulland still deny..catch(() => null)removed, so one gate cannot answer two ways for one outage.Deliberately unchanged: the master-visibility probe inside the same controlled-by-parent gate still treats a throw as "not visible" and answers 403. The two probes ask different questions — "does this row exist", which an outage leaves unanswered and which must not be answered "no", versus "is this master visible to you under your own write policy", whose fail-closed default genuinely is "not visible". The issue and the ruling both name that probe as the house posture to match, not a site to change. Pinned as a decision rather than left to read as an oversight.
Tests
17 new cases across two files, pinning both directions per caller — the steady-state answer and the fault answer, in the same describe, so "the fault propagates" cannot be satisfied by a probe that throws on everything.
controlled-by-parent-sharing.test.ts— 5 cases beside theassertControlledByParentWriteanswers a metadata defect and a missing row with the same403 PERMISSION_DENIED"requires edit access to its master record" #7474 legs they correct, including a code-less engine error (a timeout arrives as a bareError) and the deliberate 403 on the master probe.store-fault-fail-closed.test.ts(new) — 12 cases for the two provenance gates (by-id and bulk) and the owner-anchor echo, plus a blast-radius pin that an ordinary field-only update is unaffected by a faulting store.The store double is modelled field-for-field on the real
DatasourceUnavailableError:code,name,datasource, and nostatus, because that class declares none andrestroutes it off the code. Giving the double a status its producer never sets would let these cases pass against an error no engine can throw.Reverse verification (ablation). All four behaviour changes reverted:
Exactly the eight fault-path cases flip; all 36 steady-state cases stay green — which is the shape that makes them a pin rather than a restatement. The headline assertion fails as
expected 'RECORD_NOT_FOUND' to be 'ERR_DATASOURCE_UNAVAILABLE', i.e. the reverted code reproduces the reported defect verbatim.Suites run (all green, on top of
origin/mainmerged in):@objectstack/plugin-security@objectstack/runtime@objectstack/plugin-approvals@objectstack/http-conformanceConsumer sweep used the prefix filter (
...@objectstack/plugin-security— downstream consumers, not upstream deps). Also green:typecheck,eslinton the changed files,check:nul-bytes,check:engine-double-contract(the new double declares reads only, so it is out of that gate's scope for the same reason the sibling file's is),check:error-code-casing.In-flight neighbor: #7626
Same file, and no conflict — verified rather than assumed.
git merge-treeagainstclaude/issue-7626-expand-crud-bypassauto-mergessecurity-plugin.tscleanly: their hunks are lines 1194-1310, mine are 1807+, 3525+, 3631+, 3678+, 4137+ and 4196+ inorigin/maincoordinates.No behavioural interaction either. Their
expandSkipCrudregion is gated onopCtx.operation === 'find'; everyreadRowByIdcaller is on a write path (insert/update/delete/transfer/restore/purge, or the insert/update-only owner-anchor and RLS-check steps), so no operation can be in both regions at once. Their landed patch deletes the waiver outright and does not touch theisPrivatederivation — their only other change to shared vocabulary, inpackages/core/src/security/operation-private-keys.ts, is comment-only.That trial merge does surface one conflict, in
packages/objectql/src/engine.ts— a file this PR does not touch. It is between their branch and #7594 (245d1dc), which landed onmainafter their base. Theirs to resolve, flagged here only so it is not misread as coming from this card.Generated by Claude Code