Repository navigation
fix(driver-turso)!: the remote face's doors carry the caller's tenant scope, and a remote create stamps the organization (#21226) - #21245
Conversation
…aller's tenant scope, and create stamps the organization The remote doors compile the tenant predicate through the local face's own chokepoint (SqlDriver.applyTenantScope) and AND it onto each statement; the remote create stamps the caller's organization as the local create does. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…ces, and the refusal of an unreadable scope Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…he remote doors' tenant scope Clause-②: no (narrowing). The disposition is registered driver-remote-doors-tenant-scoped; registry.ts regenerated with gen:migration-registry. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…mote-doors-tenant-scope
📓 Docs Drift CheckThis PR changes 2 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 140 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 ec79b46754a214ebea34d4be76160d8a2e8821ea && git checkout ec79b46754a214ebea34d4be76160d8a2e8821ea
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ef96c9ede7dc248247280aa657090326373686de 71d3bcb35eaee6a8b26fc742a8423eb5738f31ba && git checkout -B drift-repro ef96c9ede7dc248247280aa657090326373686de && git merge --no-ff 71d3bcb35eaee6a8b26fc742a8423eb5738f31ba
node scripts/docs-audit/affected-docs.mjs --json ef96c9ede7dc248247280aa657090326373686de
|
Contract reviewServed-tier: Card #21226 (p0, Inputs. The card body and all six comments (triage grade 5938367865, unlock 5939605478, claim 5939650239, amendments 5939869353 and 5941143098, os-dev-report 5941091195); the PR body and its 6-file list; the NET diff of the head against its merge base with ① Derived judgmentsThe invariant — RIGHT. Every remote door the card names, plus the three added in place, answers the local face's row set for the same
The mechanism,
The remote
The pins ( The The two published texts — RIGHT. The PR body and the changeset name files, symbols, doors and classes; the posture in which Layer 0 is inert and the elevated caller are referred to by role; the measured detail is withheld. No request sequence, payload or reproduction appears in either. ② Semver level
③ Boundary flagsThe os-dev-report on the card carries no numbered deviation list; the five deviations readable from the report and the PR body are answered by subject.
Check-runs on the head, read last, at 2026-10-01T21:45:17Z. Of the seven required contexts, five concluded Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #21226
Clause-②: no (narrowing)
The remote (libSQL) face of
TursoDrivernow applies the caller's tenant scope at every door that reads rows or picks rows to write, and itscreatestamps the caller's organization. Each door now answers the same row set as the local face for the sameDriverOptions. This body names classes and positions only; the measured detail stays with the seats.Reach (step 1 of the card), by class
Measured at the REST data doors over a real engine with the security layer installed, remote face against local face, as a member of one organization against another organization's rows, at base
95e24b0096:creatediverged: its row landed with no organization.The seat applied triage's raise rule on the card. After this change, the same measurement shows no difference between the faces at any measured door, in all three postures and for the elevated caller.
Step 2. The hosted cloud topology is one database per environment (ADR-0002). But nothing in the repository keeps a walled multi-organization posture off a remote libSQL primary database, so one organization per remote database is not guaranteed by construction.
Mechanism
TursoDriver.remoteTenantScope(turso-driver.ts) hands the local face's own chokepoint,SqlDriver.applyTenantScope, a bare Knex query builder and compiles what it added. Compiling needs no connection, and the remote face's Knex has none. The predicate is therefore not a copy: the NULL-organization arm, the group posture'stenantIdsunion and the no-tenant-context exit all stay in that one method. If the probe compiles to a shape it cannot read (anything besidewhereterms), the call is refused withINTERNAL_ERROR/ 500. It is never sent unscoped.RemoteTransport(remote-transport.ts) takes that fragment as an optionalRemoteTenantScopeon each door and ANDs it onto the statement'sWHERE(scopedWhereSQL,byIdWhereSQL). The caller's filter is parenthesized, so a top-level$orcannot escape the scope. With no scope, every statement is byte-identical to before. This is the per-call shape the upsertfencealready uses. That fence is equality-only and cannot carry the NULL arm or the union, so the scope is its own parameter.createcallsinjectTenantOnInserton its copy of the row before the record numbers are issued, as the localcreatedoes.One conclusion per door
findfindOnecountaggregateupdate(by id, and its read-back)nulldelete(by id)falseupdateMany/deleteManybulkUpdate/bulkDeletecreate/bulkCreatebulkCreategoes throughcreateupsert95e24b009distinctfindWithWindowFunctions,analyzeQueryNo door refuses instead of scoping: the compiled predicate carries the full local scope, the group posture's union included.
Bounded in-place fix. The card names
find,findOne,count,update,delete,updateMany,deleteManyandcreate.aggregate,bulkUpdateandbulkDeletehave the same defect, in the same two files. The same helper fixes them, the same file pins them, and the same gate families cover them. Leaving them unscoped would keep the invariant false. Their pins go red with the fix reverted, as listed below.Pins
packages/drivers/driver-turso/src/turso-local-remote-tenant-scope-parity.test.tsruns on both faces: a localTursoDriver, and a remote one over the SQLite-backed libSQL stub. It uses the option shapes the engine sends when nothing above the driver composes a tenant predicate:tenantId, andtenantIdwithbypassTenantAudit. It asserts:null,falseor0, or leaves the row untouched on disk. A same-organization control answers as an unscoped call would.$orin the caller's filter does not escape the scope.tenantIdsscopes to the union and no further.createandbulkCreatestamp the caller's organization and keep an explicit one.codeandstatus) and writes nothing.Reverse verification. The fix was committed first (
e47eee6dcb). Then the two source files were restored from the base commit, with the pin file kept. The fix was then restored fromHEAD, proven by blob hash and an emptygit diff HEAD.The
packages/speceditThe changeset's FROM → TO is a migration prescription, so the ADR-0087 disposition is
registered driver-remote-doors-tenant-scoped, following the precedent landed in95e24b009:packages/spec/src/migrations/entries/semantic/18.driver-remote-doors-tenant-scoped.ts;registry.tsregion thatgen:migration-registryregenerates;'@objectstack/spec': patch.check:generatedis green before and after.driver-sqlis not touched.Verification (head
71d3bcb35e, after mergingorigin/main)pnpm --filter @objectstack/driver-turso test: 87 files, 2349 passed, 33 skipped (all in files this diff does not touch); exit 0.pnpm --filter @objectstack/driver-turso typecheck: exit 0. The pin file is in the program, counted with--listFiles.pnpm --filter @objectstack/spec check:generated: all 15 artifacts up to date. After the merge,gen:migration-registryreproducedregistry.tsbyte for byte.vitest run --project local src/migrations169 passed;tsc --noEmitexit 0.pnpm check:driver-conformance: before the first edit and after the last commit, bothOK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.pnpm check:tenant-chokepoint: green before and after (22 bindings across 3 files).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderives 90 families. All 90 ran with exit 0 on this head, and--ranreconciles 90 run, 0 NOT MEASURED.eslint --no-inline-config --format jsonover the 5 changed source files. All 5 are in the config's population (no ignored-file warnings), with 0 errors and 0 warnings. The config enables no type-aware linting, so this diff cannot change the verdict on a file it does not touch. The repo-widepnpm lintis CI's.Acceptance notes
auditMissingTenant, which the local face's write doors call. It is a warning log, not the scope, and is outside this card. Not filed; no carrier.distinctcould now carry the scope through the same helper. That would turn a refusal into an answer, which is a widening, so it is left for the seat.Generated by Claude Code