Skip to content

fix(driver-turso)!: the remote face's doors carry the caller's tenant scope, and a remote create stamps the organization (#21226) - #21245

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21226-remote-doors-tenant-scope
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21226-remote-doors-tenant-scope

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21226

Clause-②: no (narrowing)

The remote (libSQL) face of TursoDriver now applies the caller's tenant scope at every door that reads rows or picks rows to write, and its create stamps the caller's organization. Each door now answers the same row set as the local face for the same DriverOptions. 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:

  • Walled postures. The engine's Layer 0 wall held other organizations' rows back at every data door on both faces. The remote create diverged: its row landed with no organization.
  • The posture in which Layer 0 is inert, and an elevated engine caller that carries its organization. The driver scope is the only fence here. The remote face answered across organizations where the local face did not.

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's tenantIds union and the no-tenant-context exit all stay in that one method. If the probe compiles to a shape it cannot read (anything beside where terms), the call is refused with INTERNAL_ERROR / 500. It is never sent unscoped.
  • RemoteTransport (remote-transport.ts) takes that fragment as an optional RemoteTenantScope on each door and ANDs it onto the statement's WHERE (scopedWhereSQL, byIdWhereSQL). The caller's filter is parenthesized, so a top-level $or cannot escape the scope. With no scope, every statement is byte-identical to before. This is the per-call shape the upsert fence already uses. That fence is equality-only and cannot carry the NULL arm or the union, so the scope is its own parameter.
  • The remote create calls injectTenantOnInsert on its copy of the row before the record numbers are issued, as the local create does.

One conclusion per door

door remote face before now
find unscoped scoped
findOne unscoped scoped
count unscoped scoped
aggregate unscoped scoped (bounded in-place fix, below)
update (by id, and its read-back) key only key AND scope; out of scope answers null
delete (by id) key only key AND scope; out of scope answers false
updateMany / deleteMany the caller's filter only filter AND scope
bulkUpdate / bulkDelete key / id set only AND scope (bounded in-place fix, below)
create / bulkCreate no organization stamp stamps the caller's organization; bulkCreate goes through create
upsert already conformant: the fence and the stamp landed in 95e24b009 unchanged
distinct refuses a tenant-scoped call still refuses; answering it would accept a call refused today, a widening this change does not make
findWithWindowFunctions, analyzeQuery refused on the remote face unchanged

No 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, deleteMany and create. aggregate, bulkUpdate and bulkDelete have 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.ts runs on both faces: a local TursoDriver, 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, and tenantId with bypassTenantAudit. It asserts:

  • Each door, as a member of one organization against another organization's rows, answers excluded, null, false or 0, or leaves the row untouched on disk. A same-organization control answers as an unscoped call would.
  • A platform row with no organization is inside the scope on both faces.
  • A top-level $or in the caller's filter does not escape the scope.
  • The group posture's tenantIds scopes to the union and no further.
  • create and bulkCreate stamp the caller's organization and keep an explicit one.
  • A call with no tenant context reaches every row, as before.
  • A scope the remote face cannot read is refused (code and status) and writes nothing.
  • One script run on both faces gives equal answers, and they are the scoped ones.

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 from HEAD, proven by blob hash and an empty git diff HEAD.

  • With the fix reverted: 17 failed and 19 passed of 36. Every remote-face door pin, the refusal pin and the parity pin went red. Every local-face pin and the two remote-face controls stayed green.
  • With the fix restored: 36 of 36 passed.

The packages/spec edit

The changeset's FROM → TO is a migration prescription, so the ADR-0087 disposition is registered driver-remote-doors-tenant-scoped, following the precedent landed in 95e24b009:

  • one semantic entry, packages/spec/src/migrations/entries/semantic/18.driver-remote-doors-tenant-scoped.ts;
  • the registry.ts region that gen:migration-registry regenerates;
  • '@objectstack/spec': patch.

check:generated is green before and after. driver-sql is not touched.

Verification (head 71d3bcb35e, after merging origin/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-registry reproduced registry.ts byte for byte.
  • Spec: vitest run --project local src/migrations 169 passed; tsc --noEmit exit 0.
  • pnpm check:driver-conformance: before the first edit and after the last commit, both OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.
  • pnpm check:tenant-chokepoint: green before and after (22 bindings across 3 files).
  • Derived gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives 90 families. All 90 ran with exit 0 on this head, and --ran reconciles 90 run, 0 NOT MEASURED.
  • Lint, narrowed: eslint --no-inline-config --format json over 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-wide pnpm lint is CI's.

Acceptance notes

  • The remote face's write doors do not call 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.
  • distinct could 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

claude added 4 commits October 1, 2026 20:27
…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-turso, @objectstack/spec, touching 29 documentable anchor(s).

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json ef96c9ede7dc248247280aa657090326373686de.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 12 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 140 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 ef96c9ede7dc248247280aa657090326373686de → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ec79b46754a214ebea34d4be76160d8a2e8821ea — the merge of head 71d3bcb35eaee6a8b26fc742a8423eb5738f31ba into base ef96c9ede7dc248247280aa657090326373686de, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 71d3bcb35eaee6a8b26fc742a8423eb5738f31ba
Local-runs: none

Card #21226 (p0, security) · PR #21245 · branch claude/issue-21226-remote-doors-tenant-scope. Security card: this record names files, symbols, doors and classes of gap only; it carries no request sequence, payload or reproduction, and refers to the posture in which the engine's Layer 0 wall is inert by that role. Rendered at 2026-10-01T21:50Z.

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 main (ef96c9ede, the head's second parent; git diff ef96c9ede 71d3bcb35e equals GitHub's diff rendering byte for byte, index lines aside: 834 added, 49 deleted, 4 commits); the head's check-runs, read last. Source read with git show at the head and at the merge base; nothing built, run or re-run.

① Derived judgments

The invariant — RIGHT. Every remote door the card names, plus the three added in place, answers the local face's row set for the same DriverOptions, or refuses; there is no third answer.

  • find, findOne: TursoDriver.remoteTenantScope(object, options) is computed before remoteReadExit and handed to RemoteTransport.find / findOne; find's projection-fallback rungs re-pass the scope; findOne's object path goes through find, and its non-object path answers null (no unscoped by-id read remains).
  • count, aggregate: scopedWhereSQL on the statement's WHERE; aggregate narrows before grouping, as SqlDriver.aggregate scopes its builder.
  • update by id: byIdWhereSQL on the UPDATE and on the read-back; a row outside the scope is neither written nor read, and the answer is null, the local face's miss arm (the local update passes the same options through its issue closure and its read-back; rotatedUpdateById likewise).
  • delete by id: byIdWhereSQL; a miss answers false.
  • updateMany, deleteMany: scopedWhereSQL.
  • bulkUpdate: one compiled scope, applied to each row through the transport's scoped update. bulkDelete: the id set AND the scope.
  • create: injectTenantOnInsert on a copy of the row before writeRemoteRowWithAutoNumbers; bulkCreate loops TursoDriver.create (pre-existing), so every row is stamped.
  • upsert: unchanged, already conformant through the [security] driver upsert: a tenant-scoped upsert keyed on a globally-unique business column can merge into, and re-parent, another tenant's row #21185 fence and stamp. distinct: still refuses a tenant-scoped call. findWithWindowFunctions, analyzeQuery: untouched by the diff, still refused on the remote face.

The mechanism, TursoDriver.remoteTenantScope — RIGHT on all four counts.

  • (a) Reuse. It calls the protected SqlDriver.applyTenantScope on this.knex.queryBuilder() and compiles what that added. The NULL-organization arm, the group posture's tenantIds union (non-empty strings, else the equality path), the no-tenant-context exit and the no-tenant-column exit (resolveTenantField) are all read from that one method; nothing is restated. bypassTenantAudit is read only by auditMissingTenant, never by applyTenantScope, so it mutes the audit and not the scope on either face. Both faces ignore the chokepoint's return value and rely on in-place mutation, the same contract, so parity holds by construction.
  • (b) Dialect. Remote mode builds Knex with client: 'better-sqlite3' and no connection, so the probe compiles SQLite grammar: ? positional placeholders and double-quoted identifiers, which libSQL accepts. The fragment is sliced after the select * where prefix and parenthesised by the transport on both sides, so a top-level OR in the caller's filter cannot escape. Binding order is right at every door: the caller's where args then the scope args (scopedWhereSQL); id then scope (byIdWhereSQL); the id set then scope (bulkDelete); SET values, then where, then scope (updateMany); buildSelectSQL pushes where args before its ORDER BY / LIMIT args. The pins run these statements on real SQLite under the libSQL client stub.
  • (c) Fail-closed. probe.clone().clearWhere().toSQL().sql must equal select * (a limit, order, join or from added by the chokepoint refuses), and a scoped compile must open with the probe prefix; either failure throws refuseUnreadableRemoteTenantScope (INTERNAL_ERROR, status 500) before any statement is built, and on the read doors before remoteReadExit, so no envelope rewrites it and nothing is sent. Every scoped door calls the helper unconditionally.
  • (d) Unscoped byte-identity. No tenantId, or no tenant column, compiles to select * and the helper answers undefined; scopedWhereSQL then returns buildWhereSQL's own result, byIdWhereSQL returns "id" = ? with [id], bulkDelete appends nothing, and create adds no column. The statements are the ones the transport sent before.

update and delete by id — RIGHT, as above, the read-back included.

The remote create — RIGHT. Stamped before the record numbers are issued (they are per organization); an explicit organization on the row is kept, by injectTenantOnInsert's own rule. bulkCreate goes through create.

distinct — RIGHT. Answer B kept; its docblock and the REMOTE_FACE_ANSWERS comment updated. findWithWindowFunctions and analyzeQuery unchanged.

The pins (turso-local-remote-tenant-scope-parity.test.ts) — they prove what they claim. One loop over both faces (local :memory:, remote over the SQLite-backed libSQL stub) pins each door as a member of one organization against two other organizations' rows and a platform row: find under both scoped option shapes, the top-level $or, findOne, count, aggregate, update, delete, updateMany, deleteMany, bulkUpdate, bulkDelete, create, bulkCreate, the tenantIds union (find, count, an update miss and a member hit), and the no-context control; disk state is read raw from each face's own database, so "untouched" is proven rather than inferred, and each door's same-organization control answers as before. The refusal pin subclasses the chokepoint to add a non-where term and proves INTERNAL_ERROR / 500 on a read, a by-id write and a predicate write, with the stored rows unchanged. The final pin runs one script on both faces, requires equal answers, then requires the agreed answer to be the scoped one, which closes the "both faces wrong together" hole. The reverse-verification counts in the PR body (17 red of 36 with the fix reverted) reconcile with the roster: 15 remote-face door pins plus the refusal and parity pins. Two observations, neither a gap: the elevated-caller shape (tenantId with bypassTenantAudit) is exercised on find only, sufficient because the chokepoint never reads that flag; and the helper's second refusal arm (a scoped compile without the probe prefix) has no pin, being unreachable while Knex emits that prefix.

The packages/spec entry — RIGHT. entries/semantic/18.driver-remote-doors-tenant-scoped.ts: the numeric prefix is the protocol major the generator keys on, so it lands in step18, the open step (the #21185 sibling 18.driver-upsert-cross-organization-conflict-refused.ts sits beside it, same shape, same comment form), and the registry.ts region under step18 carries the entry's fields verbatim. The prose names the twelve doors, the miss answers, the stamp, distinct's refusal and that no signature changes; it moves no accept set (no schema, no conversion, no tombstone). check:generated is inside the green Lint & Repo Gates.

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

.changeset/21226-remote-doors-tenant-scope.md — RIGHT. '@objectstack/driver-turso': minor, '@objectstack/spec': patch; the title carries !; Clause-②: no (narrowing); exactly one disposition marker, adr-0087: registered driver-remote-doors-tenant-scoped, whose id resolves in registry.ts at the head and is new in this diff, as the gate's registered arm requires; a FROM → TO table of four rows plus a "not affected" paragraph. minor is the level a declared narrowing takes under the launch-window guard (check-changeset-no-major forbids major), and the #21185 precedent .changeset/21185-upsert-cross-org-refusal.md carries the identical shape. (narrowing) is right: every scoped door moves from an answer to a subset of it, and distinct keeps its refusal, so nothing widens. Check Changeset on the head is success.

③ Boundary flags

The 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.

  1. Scope: three doors fixed in place (aggregate, bulkUpdate, bulkDelete) — accepted. Same defect class, same two files, same helper, same pin file, same gate families; leaving them would keep the invariant false. Recorded by the seat's amendment 5941143098.
  2. Files: the packages/spec entry, its registry.ts region and '@objectstack/spec': patch — accepted. The claim allowed exactly this if the ADR-0087 gate asked for registered; the FROM → TO table is a migration prescription, so it did.
  3. Verification: lint narrowed to the five changed source files — accepted; the repo-wide verdict is CI's Lint & Repo Gates, green on the head.
  4. Verification: check:dual-build-cjs-loads PREREQUISITE NOT MET on the earlier head 10fa4bc9b8, measured on the rerun at the head — accepted as reported; the 90 derived families reconcile to 0 NOT MEASURED there.
  5. No committed door-level pin — accepted. packages/rest does not depend on driver-turso, so a REST-door pin against a remote-mode driver would be a cross-package test input; the committed pins sit at the driver door on both faces, where ADR-0131 D8 places the invariant, and Layer 0 keeps its own pins. Consequence, for the seat: the step-1 reach measurement is not repeatable from the repository; the seat holds it.
  • distinct, answer B — accepted. The refusal is triage's conformant answer for a door left unscoped and keeps Clause-②: no (narrowing) honest; scoping it later is one call of the same helper plus its own Clause-② line, on a card of its own if a caller appears.
  • The remote write doors do not call auditMissingTenant — accepted as an acceptance note, the carrier Prime Directive chore: version packages #10 prescribes for a non-defect. The audit is a once-per-door warning for a write that carries no tenantId under a walled posture, so it is orthogonal to the scope, which applies only when a tenantId is present; rows are unaffected. If the seat wants the warning on both faces, it is a follow-up on its own card.
  • Step 2's reading — accepted. One database per environment is the hosted topology (ADR-0002, ADR-0095); nothing in the repository holds a multi-organization walled posture off a self-hosted remote libSQL primary, so the drop rule cannot be met by construction, and the raise rule stood on the step-1 answer at a public door. Triage's option to re-grade, recorded in 5939869353, remains triage's.
  • Observation, not a published-text finding: the remoteTenantScope docblock in turso-driver.ts names the wall-less posture by its ADR-0105 D1 name rather than by role; a documented posture name, no recipe, and the source already stated this gap at distinct before the PR. The seat may ask for the role wording for consistency.
  • Landing state: draft, auto_merge unset, 883 changed lines, no governed-surface path in the file list, head repository is the base repository. origin/main has advanced to be5a83cf since the merge base; the queue rebuilds on it.

Check-runs on the head, read last, at 2026-10-01T21:45:17Z. Of the seven required contexts, five concluded success: Lint & Repo Gates, Build Core, Dogfood Regression Gate, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Two are not yet verdicts: Test Core (shards 2/6 and 4/6 success; 1/6, 3/6, 5/6 and 6/6 in progress; rollup not yet reported) and TypeScript Type Check (Type Check · source gates, · consumer gates and · debt ledger success; Type Check · workspace in progress; rollup not yet reported). Check Changeset is success. The seat confirms the green bar itself before enqueue.

Implemented-by: claude/issue-21226-remote-doors-tenant-scope
Reviewed-by: session_017xfMoEjKUuSh2xYB8sCozp

VERDICT: PASS


Generated by Claude Code

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