@@ -4739,21 +4739,35 @@ export class ObjectStackProtocolImplementation implements
47394739
47404740 async updateData ( request : { object : string , id : string , data : any , expectedVersion ?: string , context ?: any } ) {
47414741 this . assertObjectRegistered ( request . object ) ; // [#3770]
4742- await this . assertVersionMatch ( request . object , request . id , request . expectedVersion , request . context ) ;
4743- // [#4435] A PATCH of an id that names no row answered `200 { record:
4744- // null }` — the caller had to null-check a SUCCESS payload to discover
4745- // its write never landed, which is exactly what a client that PATCHes a
4746- // concurrently deleted record does not do. `getData` has always answered
4747- // 404 for the same id; the two verbs now agree.
4742+ // [#4435] ONE probe serves both gates.
47484743 //
4749- // Checked BEFORE the write rather than by inspecting what comes back:
4750- // the engine's update returns the post-write READBACK, which is also
4751- // `null` when the row still exists but the write moved it out of the
4752- // caller's row scope (reassigning `owner_id` away from yourself under an
4753- // owner-scoped RLS policy). Reading that as "not found" would answer 404
4754- // to a write that succeeded. Existence is the question, so ask it
4755- // directly — the same `findOne` + context `getData` asks.
4756- await this . assertRecordExists ( request . object , request . id , request . context ) ;
4744+ // A PATCH of an id that names no row answered `200 { record: null }` —
4745+ // the caller had to null-check a SUCCESS payload to discover its write
4746+ // never landed, which is exactly what a client that PATCHes a
4747+ // concurrently deleted record does not do. `getData` has always
4748+ // answered 404 for the same id; the two verbs now agree.
4749+ //
4750+ // Existence is asked BEFORE the write rather than read off what comes
4751+ // back: the engine's update returns the post-write READBACK, which is
4752+ // also `null` when the row still exists but the write moved it out of
4753+ // the caller's row scope (reassigning `owner_id` away from yourself
4754+ // under an owner-scoped RLS policy). Reading that as "not found" would
4755+ // answer 404 to a write that succeeded. So ask existence directly.
4756+ //
4757+ // The probe asks EXISTENCE, not visibility — see `probeRecord` for why
4758+ // that distinction is load-bearing (it keeps this fix out of the RLS
4759+ // model and keeps the #1994 by-id-write proof able to go red).
4760+ //
4761+ // OCC already had to read the same row for its `updated_at`, so the two
4762+ // gates share this single read instead of issuing a probe each. Two
4763+ // round-trips per PATCH would have been a performance regression no
4764+ // gate reports, and the second read could even disagree with the first.
4765+ const current = await this . probeRecord ( request . object , request . id ) ;
4766+ if ( ! current ) throw recordNotFoundError ( request . object , request . id ) ;
4767+ // 404 wins over 409 when both could apply: OCC has always declined to
4768+ // treat a missing record as a concurrency conflict, and "this record
4769+ // does not exist" is the more specific answer.
4770+ this . assertVersionOf ( request . object , request . id , current , request . expectedVersion ) ;
47574771 const opts : any = { where : { id : request . id } } ;
47584772 if ( request . context !== undefined ) opts . context = request . context ;
47594773 // [#3407/#3431] Capture the engine's LEGAL write strips (static `readonly`
@@ -4775,7 +4789,7 @@ export class ObjectStackProtocolImplementation implements
47754789
47764790 async deleteData ( request : { object : string , id : string , expectedVersion ?: string , context ?: any } ) {
47774791 this . assertObjectRegistered ( request . object ) ; // [#3770]
4778- await this . assertVersionMatch ( request . object , request . id , request . expectedVersion , request . context ) ;
4792+ await this . assertVersionMatch ( request . object , request . id , request . expectedVersion ) ;
47794793 const opts : any = { where : { id : request . id } } ;
47804794 if ( request . context !== undefined ) opts . context = request . context ;
47814795 const deleted = await this . engine . delete ( request . object , opts ) ;
@@ -4801,50 +4815,72 @@ export class ObjectStackProtocolImplementation implements
48014815 }
48024816
48034817 /**
4804- * [#4435] Throw `404 RECORD_NOT_FOUND` when `id` names no row VISIBLE to
4805- * this caller — the same question, asked the same way, that `getData`
4806- * answers with. Threading `context` is what makes "visible to this caller"
4807- * true: a row the caller's RLS scope excludes is not-found for them on the
4808- * read path, and a write path that disagreed would answer 200 for a record
4809- * the very next GET reports as 404.
4818+ * [#4435] Does this row EXIST? A fact about the database — deliberately
4819+ * NOT "may this caller see it".
4820+ *
4821+ * Read with a system context so no row-level policy narrows it. That is
4822+ * load-bearing, and the first version of this fix got it wrong: probing
4823+ * with the CALLER's context turns the existence gate into an authorization
4824+ * gate, because a row the caller cannot read comes back `null` and the
4825+ * PATCH answers 404. Two things break when it does.
4826+ *
4827+ * 1. It silently changes RLS semantics. Whether an unreadable row may be
4828+ * written by id is the #1994 pre-image check's decision, made inside
4829+ * `engine.update` where the write policy lives. A probe in front of it
4830+ * quietly adds a second, different rule — scope creep into the security
4831+ * model, from a bug fix about missing records.
4832+ *
4833+ * 2. It disarms a revert-provable security proof. `@proof: rls-by-id-write`
4834+ * (`packages/qa/dogfood/test/rls-fixture.dogfood.test.ts`, referenced by
4835+ * the `permission.rowLevelSecurity.using` liveness ledger entry) boots a
4836+ * fixture whose member can read nothing but has no write policy, and
4837+ * asserts the runner reports `rls-hole`. A caller-scoped probe 404s that
4838+ * PATCH, so the RED half goes green — and the gate can no longer prove
4839+ * it is able to go red. If the #1994 fix were ever reverted, this probe
4840+ * would MASK it. Accidentally hardening one path is not worth
4841+ * permanently blinding the gate that watches the whole class.
4842+ *
4843+ * So the probe answers existence only, and authorization stays exactly
4844+ * where it was. The one behaviour this adds is the 404 the issue asked for:
4845+ * an id that names no row at all.
4846+ *
4847+ * One place, because two gates need this row — the existence gate and OCC's
4848+ * `updated_at` comparison — and issuing a probe each would put two
4849+ * round-trips on every PATCH.
48104850 */
4811- private async assertRecordExists ( object : string , id : string , context : any ) : Promise < void > {
4812- const findOpts : any = { where : { id } } ;
4813- if ( context !== undefined ) findOpts . context = context ;
4814- const existing = await this . engine . findOne ( object , findOpts ) ;
4815- if ( ! existing ) throw recordNotFoundError ( object , id ) ;
4851+ private async probeRecord ( object : string , id : string ) : Promise < any > {
4852+ return this . engine . findOne ( object , { where : { id } , context : { isSystem : true } } as any ) ;
48164853 }
48174854
48184855 /**
4819- * Optimistic Concurrency Control gate shared by updateData/deleteData.
4856+ * Optimistic Concurrency Control — the COMPARISON half, over a row the
4857+ * caller has already read. Pure: it issues no query of its own, which is
4858+ * what lets `updateData` run the gate on its existence probe's result
4859+ * rather than re-reading the record (#4435).
48204860 *
4821- * When the caller passes a non-empty `expectedVersion` token (typically
4822- * the `updated_at` value they read), this fetches the current record
4823- * and compares its `updated_at` against the token. Mismatch → throw
4824- * `ConcurrentUpdateError` which the REST layer maps to 409.
4861+ * When the caller passes a non-empty `expectedVersion` token (typically the
4862+ * `updated_at` value they read), a mismatch throws `ConcurrentUpdateError`,
4863+ * which the REST layer maps to 409.
48254864 *
48264865 * Behaviour:
48274866 * - Empty/missing token → no check (opt-in semantics; existing callers
48284867 * that haven't yet adopted OCC are unaffected).
4829- * - Record not found → no check; downstream `engine.update` will
4830- * surface the usual `RECORD_NOT_FOUND` 404. We intentionally do not
4831- * treat "missing record" as a concurrency conflict .
4868+ * - Record not found → no check. We intentionally do not treat "missing
4869+ * record" as a concurrency conflict; `updateData` has already answered
4870+ * 404 by this point, and `deleteData` lets the driver report it .
48324871 * - Record has no `updated_at` field (timestamps disabled) → no check.
48334872 * Logging would be noisy here; OCC is opt-in and the absence of a
48344873 * version column is an explicit "this object doesn't support OCC"
48354874 * signal.
48364875 */
4837- private async assertVersionMatch (
4876+ private assertVersionOf (
48384877 object : string ,
48394878 id : string ,
4879+ current : any ,
48404880 expectedVersion : string | undefined ,
4841- context : any
4842- ) : Promise < void > {
4881+ ) : void {
48434882 const expected = normaliseVersionToken ( expectedVersion ) ;
48444883 if ( ! expected ) return ;
4845- const findOpts : any = { where : { id } } ;
4846- if ( context !== undefined ) findOpts . context = context ;
4847- const current = await this . engine . findOne ( object , findOpts ) ;
48484884 if ( ! current ) return ;
48494885 const currentVersion = normaliseVersionToken ( ( current as any ) . updated_at ) ;
48504886 if ( ! currentVersion ) return ;
@@ -4857,6 +4893,22 @@ export class ObjectStackProtocolImplementation implements
48574893 }
48584894 }
48594895
4896+ /**
4897+ * OCC gate for `deleteData`, which — unlike `updateData` — needs no
4898+ * existence probe of its own: the driver's own `delete` return reports
4899+ * whether a row matched (#4435). So this still probes ONLY when the caller
4900+ * actually opted into OCC, keeping a plain DELETE at zero extra reads.
4901+ */
4902+ private async assertVersionMatch (
4903+ object : string ,
4904+ id : string ,
4905+ expectedVersion : string | undefined ,
4906+ ) : Promise < void > {
4907+ if ( ! normaliseVersionToken ( expectedVersion ) ) return ;
4908+ const current = await this . probeRecord ( object , id ) ;
4909+ this . assertVersionOf ( object , id , current , expectedVersion ) ;
4910+ }
4911+
48604912 // ==========================================
48614913 // Global Search (M10.5)
48624914 // ==========================================
0 commit comments