Skip to content

feat(driver-sql,objectql)!: a tenant-scoped read no longer answers a row with no organization (18.x step 7, part 7a-1) - #24825

Merged
objectstack-fleet[bot] merged 54 commits into
mainfrom
claude/issue-24583-7a1-driver-null-arm
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 54 commits into
mainfrom
claude/issue-24583-7a1-driver-null-arm

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #24583 (18.x step 7, part 7a-1: the driver's NULL-organization arm). The card stays open for the other step-7 parts (7b-1, 7b-2, 7b-3).

Clause-②: no (narrowing)

Landing order. This branch started from main merged with claude/issue-24581-ceremony-open (#24681, steps 5+6), because it retires readers that only certification makes dead. #24681 has since merged (c6f775cbd), and this branch has merged main again (307ce4c4b). The diff against main is this part alone: 19 files, +332 / −241, no governed path (check-governed-merges --branch: 0 of 19 on the register, under the 3,000-line line).

What changes

  • SqlDriver.applyTenantScope (packages/drivers/driver-sql/src/sql-driver.ts). Both orWhereNull arms are gone. A tenant scope compiles to organization_id = :tenant, or organization_id IN (…) under group. A row with no organization is no tenant's (ADR-0131 D1, D14). With no tenant on the call, or no tenant column, the read stays unscoped, as before. The Turso remote face compiles the same chokepoint, so it follows by construction; only its two docblocks changed.
  • readBackInsertedRows (same file). The docblock is restated. One consequence of the retired arm is handled in code: the rows' own tenant union scopes the read-back only when every written row carries a tenant. A batch with no tenant on the call that mixes stamped and unstamped rows would otherwise miss its own unstamped row after it landed, and the refusal would fire after the write. The ids are this call's own primary keys, so the read cannot reach a row this call did not write.
  • LifecycleService global retention / archive pass (packages/objectql/src/lifecycle/lifecycle-service.ts). On an organization_id partition the { [column]: null } disjunct is gone and the bare $nin stays. ⚠️ The disjunct is kept on the attribution partition (sys_audit_log.tenant_id, ADR-0131 D7). There, NULL is a live, deployment-level value and not a pre-ceremony one: on the ceremonied 18.0 fixture, sys_audit_log.tenant_id holds 14 NULL rows of 172. tenantWindowsFor makes the decision once, as TenantPartition.nullIsLive, so the passes name no column of their own (the federated-reader ledger's existing tenantWindowsFor row stays true).
  • objectql engine docblocks that named the driver's OR … IS NULL (buildDriverOptions's federated note, the reference-probe and master-header notes).
  • Pins. Tests that asserted the retired reach now assert the new rule. Fixtures whose NULL row was incidental are re-stamped. Where an organization-less row is the probe, it stays and is now asserted unreached (the driver tenant-scope and read-door suites, the Turso local/remote parity suite, plugin-sharing's rule-sweep / record-share / share-link stamps, service-storage's upload-door writes).

Premise check: is every retired reader dead?

Population Reading
Fresh single install (pnpm dev:crm -- --fresh), after seeded-admin sign-in and a REST create 35 tables carry organization_id; 0 NULL rows. The session carries an active organization.
Certified database (ceremoniedFixture over the 18.0 fixture) 66 tables carry organization_id; 0 NULL rows. sys_audit_log.tenant_id: 14 / 172 NULL (the kept attribution arm).
Walled boot (isolated os serve, real @objectstack/organizations) 30 tables; 0 NULL rows.
single posture reads The driver carries no tenant scope at all (ADR-0131 D8), so the arm was never reached there.
Composition with no organization object (no-organization-object) It still writes NULL rows, but nothing there carries a tenant to the driver. Session, guest and system writes resolve none, so applyTenantScope exits before the arm. Shipped compositions: examples/embed-objectql (memory driver, which refuses tenant scope); every os serve composition with Auth registers sys_organization (plugin-auth).

Proof (head 2ee8e8d87, after the last main merge)

  • R1. pnpm dev:crm -- --fresh -p <random> from a full build: health 200, seeded-admin sign-in 200, crm_account create 201, list 200, 0 error lines.
  • R2 + R3. upgrade-18x-harness.dogfood.test.ts + organization-ownership-boot-refusal-order.dogfood.test.ts: 12 / 12.
  • R4. The built CLI (bin/run-dev.js serve), OS_TENANCY_POSTURE=isolated with the walled prerequisites, on a throwaway app that declares the real @objectstack/organizations. It reached the banner, Tenancy: isolated, Organizations mounted, discovery 200, health 200, 0 refusals; the database it created holds 0 NULL organization_id rows in 30 tables. Step 4's seven walled e2e cases (OS_TEST_TIERS=nightly): 7 / 7.
  • Full suites, on the final head after a full build. driver-sql: 223 files, 3682 passed. objectql: 451 files, 8495 passed. driver-turso: 92 files, 2430 passed. plugin-sharing: 51 files, 1193 passed. typecheck exit 0 for objectql, driver-sql and driver-turso. Downstream: the 92 test files in 13 packages that reach an organization-less row through the real SQL driver, all green (1636 tests). The 5 among them that pinned the retired reach are rewritten here.
  • Ablation (scripts/ablation-replace.mjs at 9987cf807, the same driver code as the final head; mutation landed and restored to blob == HEAD, git diff HEAD empty). Re-adding orWhereNull on the equality arm turns 4 pins red in sql-driver-tenant-scope.test.ts. Reverting the read-back guard turns the new mixed-batch pin red on the read-back face.
  • Gates. node scripts/pm/dispatch-gates.mjs --commands on the final head: 78 commands. Each was run with its exit code captured before any pipe, and all 78 exited 0. --ran reconciliation: 78 derived, 78 run, 0 NOT-MEASURED (a derived zero: every line carries its exit code).

Acceptance notes

  • The claim's file list named driver-sql, the Turso parity tests, objectql's lifecycle and engine docblocks, and "the tenant-scope tests". Measured, those tests also live in plugin-sharing (3 files), service-storage (1 file) and driver-sql's driver-sql on MySQL: create() answers the insert id (0) instead of the inserted record, so sign-up answers 400 FAILED_TO_CREATE_USER, the dev admin seed fails and no user can sign in #21227 read-back suite (1 new case). Their pins asserted the retired reach and are rewritten here. The one code line beyond the claim's wording is readBackInsertedRows's scope guard (the claim named its docblock); it is the read-back consequence of the retired arm, and it is in the same function. packages/drivers/driver-turso/src/{turso-driver,remote-transport}.ts changed in docblocks only.
  • engine.ts's dangling-reference audit docblock ("a NULL-organization row … probes unscoped") is unchanged. It describes that audit's own code, which this part does not touch.
  • packages/runtime/src/domains/packages.ts still says env-wide sys_metadata reads are organization_id IS NULL. sys_metadata has carried no organization column since 18.sys-metadata-organization-column-retired (systemFields: { tenant: false }), so the driver arm never applied there. Text drift, left for its owner.
  • service-storage's driver-leg legacy row was seeded with tenantId: 'org_A', so injectTenantOnInsert stamped it and the old "stays reachable" pin was vacuous. It is now seeded with no tenant and asserted unreached.
  • Not touched: the schema differ, index keys and COALESCE (7b-1 / 7b-2), __global__ (7b-3), docs/adr/**.

Generated by Claude Code

claude added 30 commits October 19, 2026 13:06
…r retired; webhook activation door

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…he 18.0 dual read; door pins for webhook

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…asserts no row and a dispatch

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…the checklist, the webhooks guide and the showcase; changeset

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…tcher's switch read

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…posture builds no wall, as its 18.0 seeded copy stamped with the Default Organization was

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…red writes; the declared-webhook double is drivable by the limit gate

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…does not implement

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…authors; checklist and admitsOrganization docblock say a declared webhook is deployment-wide under single

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…the fixture's customized declared-webhook mirror

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…n fail on a double delivery

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…0g-webhooks-registry

# Conflicts:
#	content/docs/permissions/tenant-audit-census.mdx
#	docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
…0g-webhooks-registry

# Conflicts:
#	content/docs/permissions/tenant-audit-census.mdx
#	docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
…-registry' into claude/issue-24581-ceremony-open
…he ceremony, then boot; boot-refusal-order step 2 back on the binary
…el — the cipher names no organization

Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V
Co-authored-by: Claude <noreply@anthropic.com>
…sys_flow_credential leave reach)

Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V
Co-authored-by: Claude <noreply@anthropic.com>
…he 18.x rule

plugin-sharing (rule sweep, record-share stamp, share-link stamp),
service-storage (the upload doors' scoped writes) and driver-sql's
read-back: an organization-less row is no tenant's, so no scoped read or
write reaches it.

Claude-Session: https://claude.ai/code/session_01DDkA1TNK1gGKSujgXiLLZQ
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 20, 2026
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/driver-sql, @objectstack/driver-turso, @objectstack/objectql, touching 11 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/drivers/driver-turso/src/remote-transport.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via LifecycleService (symbol, a top-level class), SqlDriver (symbol, a top-level class), TursoDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/objects.mdx (via LifecycleService (symbol, a top-level class))
  • content/docs/data-modeling/queries.mdx (via SqlDriver (symbol, a top-level class), applyTenantScope (symbol, a method of class SqlDriver), findWithWindowFunctions (symbol, a method of class SqlDriver))
  • content/docs/kernel/services.mdx (via LifecycleService (symbol, a top-level class))
  • content/docs/permissions/attachments-access.mdx (via LifecycleService (symbol, a top-level class))
  • content/docs/permissions/tenant-audit-census.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SqlDriver (symbol, a top-level class), TursoDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/knowledge.mdx (via LifecycleService (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via SqlDriver (symbol, a top-level class), TursoDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))
  • content/docs/protocol/objectql/types.mdx (via SqlDriver (symbol, a top-level class))

⛔ 7 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx (via LifecycleService (symbol, a top-level class))
  • content/docs/releases/v15.mdx (via LifecycleService (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-1.mdx (via applyTenantScope (symbol, a method of class SqlDriver))
  • content/docs/releases/v17/17-4.mdx (via findWithWindowFunctions (symbol, a method of class SqlDriver))
  • content/docs/releases/v17/17-5.mdx (via SqlDriver (symbol, a top-level class), TursoDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-6.mdx (via SqlDriver (symbol, a top-level class), TursoDriver (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/drivers/driver-turso/src/remote-transport.ts) — pages documenting those are invisible to this run
  • 2 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages), organization_id (literal, 35 pages)
  • 1 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 — 26 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 c3a8e07b6aca321c0114f49944b0564baf0d2c0f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 46e8fb2d681b86eb8ba0ef482e810adee61650b3 — the merge of head 5124ae06c29791dff42e5517d9838f2a096fd1b7 into base c3a8e07b6aca321c0114f49944b0564baf0d2c0f, 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 46e8fb2d681b86eb8ba0ef482e810adee61650b3 && git checkout 46e8fb2d681b86eb8ba0ef482e810adee61650b3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c3a8e07b6aca321c0114f49944b0564baf0d2c0f 5124ae06c29791dff42e5517d9838f2a096fd1b7 && git checkout -B drift-repro c3a8e07b6aca321c0114f49944b0564baf0d2c0f && git merge --no-ff 5124ae06c29791dff42e5517d9838f2a096fd1b7

node scripts/docs-audit/affected-docs.mjs --json c3a8e07b6aca321c0114f49944b0564baf0d2c0f

⚠️ 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 c3a8e07b6aca321c0114f49944b0564baf0d2c0f → 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: 2ee8e8d877cca3ca036f19c2f8e615ed74fe0a39
Local-runs: none
Scope: packages/drivers/driver-sql/src/sql-driver.ts, packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts, packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts, packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts, packages/drivers/driver-turso/src/turso-driver.ts, packages/drivers/driver-turso/src/remote-transport.ts, packages/drivers/driver-turso/src/turso-local-remote-tenant-scope-parity.test.ts, packages/drivers/driver-turso/src/turso-remote-inherited-members.test.ts, packages/objectql/src/engine.ts, packages/objectql/src/lifecycle/lifecycle-service.ts, packages/objectql/src/lifecycle/lifecycle-service.test.ts, packages/objectql/src/lifecycle/lifecycle-service.attribution-partition.test.ts, packages/objectql/src/lifecycle/lifecycle-service.no-tenant-column.test.ts, packages/objectql/src/lifecycle/lifecycle-service.organization-registry.test.ts, packages/plugins/plugin-sharing/src/record-share-organization-stamp.test.ts, packages/plugins/plugin-sharing/src/rule-criteria-org-scope.test.ts, packages/plugins/plugin-sharing/src/share-link-organization-stamp.test.ts, packages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.ts + .changeset/24583-7a1-tenant-scope-null-arm-retired.md, docs/adr/0131-total-organization-ownership-no-null-organization-id.md, docs/adr/0087-metadata-protocol-upgrade-contract.md, AGENTS.md, packages/core/src/security/tenant-layer0-verdict.ts, packages/core/src/security/system-write-organization.ts, packages/core/src/security/resolve-authz-context.ts, packages/objectql/src/tenancy/system-write-organization.ts, packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts, packages/plugins/plugin-sharing/src/share-link-service.ts, packages/plugins/plugin-sharing/src/sharing-rule-service.ts, packages/plugins/plugin-security/src/organization-scope.ts, packages/plugins/plugin-security/src/delegated-admin-gate.ts, packages/plugins/plugin-approvals/src/approval-service.ts, packages/plugins/organizations/src/claim-orphan-org-rows.ts, packages/services/service-messaging/src/http-outbox.ts, packages/services/service-messaging/src/sql-http-outbox.ts, packages/runtime/src/domains/packages.ts, packages/spec/src/api/events.zod.ts, packages/cli/src/commands/serve.ts, scripts/check-tenant-chokepoint.mjs, scripts/check-changeset-no-major.mjs, scripts/pm/clause2-line.mjs, scripts/pm/record-recognisers.mjs

Method: the diff 307ce4c4b..2ee8e8d87 (19 files, +332/-241) read in a detached worktree at the head, the card #24583 and its claim (6284500102) and report (6286802345), ADR-0131 D1/D7/D8/D9/D14, the governing scripts, and the head's check runs over the GitHub API. Nothing built, run or re-run. Every measured number below (0 NULL rows on 35/66/30 tables, 14/172, suite counts, ablation) is the dev's; this record confirms the rule by code reading and the pins by reading the assertions.

① Derived judgments

1. The arms — correct. SqlDriver.applyTenantScope (packages/drivers/driver-sql/src/sql-driver.ts:15587-15622): the group path is builder.whereIn(field, tenantIds.map(String)) at :15609 and the equality path is builder.where(field, String(tenantId)) at :15621; neither orWhereNull remains, and the two exits (no tenant on the call :15592, no tenant field :15594) are untouched. A grep of driver-sql/src non-test code finds whereNull/orWhereNull only in the caller-authored $null/$exists filter lowering (:16640-16654), which is a predicate the caller wrote, not the scope. The Turso remote face follows by construction: TursoDriver.remoteTenantScope (packages/drivers/driver-turso/src/turso-driver.ts:2538-2550) hands applyTenantScope a bare builder and compiles whatever it added; driver-turso/src non-test code has no IS NULL on a tenant column; its two hunks (turso-driver.ts:2523-2524, remote-transport.ts:984) are docblocks. Posture: computeTenantLayer0Verdict (packages/core/src/security/tenant-layer0-verdict.ts, first line of the body) answers none whenever postureEnforcesWall is false, so under single ObjectQL.driverReadScope (packages/objectql/src/engine.ts:7180-7215) returns undefined and buildDriverOptions (engine.ts:7073-7125) threads no tenantId: the driver takes its first exit and no scope applies. Under isolated the verdict is organization and the predicate is exactly organization_id = :t; under group it is organizations and the predicate is IN (...). The write target (driverWriteTarget, engine.ts:7222-7230, the one writeTarget: true site at engine.ts:16310) threads the active organization on inserts in every posture; those rows are stamped by injectTenantOnInsert (sql-driver.ts:15668-15680), so the insert read-back's union covers them (judgment 4). Anything else reading NULL organization rows THROUGH the driver's scope: none. What remains are explicit filters above the driver — approval-service.ts:2761 businessUnitOrgScope, sharing-rule-service.ts:955 adminOrgScope and :1218 (the by-name NULL fallback), organization-scope.ts:106-139 resolveOwnOrganizationRow's residue branch with its caller delegated-admin-gate.ts:1294, and claim-orphan-org-rows.ts:94 (a system-context repair, unscoped by design). Under a wall the driver's equality now ANDs over each $or, so their NULL half is dead code; under single there is no driver scope and they read what they always read. They are 8c's "authz NULL readers" (step plan 6268559156 line 260 names grantAppliesInTenant and criteriaContext), another part of step 7, not this one — flagged in ③.

2. The premise — holds by code reading. (a) single: LAYER0_NONE means the arm was never reached with a tenant on any read door. (b) A composition that registers no organization object: resolveSystemWriteOrganization (packages/core/src/security/system-write-organization.ts:86, :122) answers no-organization-object; the engine's system-insert door (engine.ts:7427) and the share-link minter (share-link-service.ts:763) then return undefined and stamp nothing; the guest entry's organization step (resolve-authz-context.ts, resolveGuestOrganization, the D4 docblock at ~1215-1245) runs the same decision and resolves no organization; a session's tenantId is an active organization, which cannot exist without sys_organization, and with no AuthPlugin there is no session at all. So none of session, guest or system carries a tenant into applyTenantScope there; the arm was unreachable with a tenant, and its removal changes no answer. Known context (production os serve, no OS_AUTH_SECRET): packages/cli/src/commands/serve.ts:4133-4166 — resolvePlatformAuthComposition does not compose, serve prints the yellow "AuthPlugin skipped — set OS_AUTH_SECRET to enable authentication in production" warning and continues; serve.ts:4648 [#3963] denies anonymous object data unconditionally. On that boot the posture resolves single (and if the operator forced a wall, Layer 0 denies every data operation that carries no organization and the driver sees no tenant), so the driver carries no scope and every read and write is what it was before this PR; the boot still seeds NULL rows, which 7b-3 (6284744613, option A) is making a refusal. Confirmed: this PR alone changes nothing an operator sees on such a boot.

3. The kept NULL disjunct — right call; it must not retire. sys_audit_log declares systemFields: { tenant: false } (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts:31) and tenant_id as a plain Field.lookup('sys_organization', { required: false }) (:398-402). resolveTenantFieldName (packages/objectql/src/tenancy/system-write-organization.ts:198-213) claims only a declared tenancy.tenantField or the injected organization_id; the ledger has neither, so it answers null, and the driver's twin computeTenantField honours the same opt-out, so resolveTenantField('sys_audit_log') is null and applyTenantScope exits before any predicate. tenant_id is therefore not a tenant column under D8; it is exactly D7's "plain attribution field under a name the tenant-field resolver does not claim, never the tenancy anchor". D1's "no nullable tenant column anywhere" does not reach a non-tenant column, and check-tenant-chokepoint scans only the two driver files (scripts/check-tenant-chokepoint.mjs:124-126), not the lifecycle service, so there is no gate conflict. The decision is made once: tenantWindowsFor (packages/objectql/src/lifecycle/lifecycle-service.ts:1620-1639) answers nullIsLive: false on organization_id (:1632) and true on an ATTRIBUTION_PARTITION_COLUMNS entry (:1636), and globalPassSelector (:272-276) is the only place that spells the $or. Retiring it would be wrong: with any tenant override on the ledger, a bare $nin on tenant_id never selects a deployment-level row (a value that is not there satisfies no positive comparison, #5298/#5299), so ADR-0057 retention would silently stop for exactly the rows the ledger exists to age out. The claim's literal "the { [column]: null } disjunct goes" is correctly narrowed to the organization_id partition; the dev flagged the deviation (open question 2) — answered A in ③.

4. The readBackInsertedRows guard — correct, minimal, within the part. sql-driver.ts:7635-7670, guard at :7654-7659. written is the rows AFTER injectTenantOnInsert (create: :7508 then :7572; bulkCreate: :9857 then :9986), which fills every absent, null or empty tenant column when the call carries a tenant. Cases: (i) call with tenant → every row stamped → the union IN (written) scopes the read, the caller's tenantIds membership set replaced as before; (ii) call without tenant, every row explicitly stamped → the same union; (iii) call without tenant, mixed or unstamped rows → { ...options }, no tenant, unscoped, bound by whereIn(idColumn, written ids). A call with a tenant and an unstamped row cannot occur. Leak: none — in (iii) the read is bound to this call's own PRIMARY KEY ids, so it can answer only rows this call wrote, the same rows the old IN (...) OR IS NULL read answered. Miss: none — (iii) is the case the old arm served; it is now served by having no scope rather than a NULL arm, which is the D8-conformant shape. Without the guard, a no-tenant mixed bulkCreate on a dialect whose INSERT answers no rows would refuse (insertedRowsNotReadBackError) AFTER the write landed. The claim named this function's docblock; the guard is the same function's read-back consequence of the retired arm and the part is not complete without it — not a breach (open question 3, answered A). Pinned by the new #21227 case on both faces of that suite.

5. Tests — every rewritten pin asserts the new rule; nothing weakened to green. sql-driver-tenant-scope.test.ts: three reach pins become five (scoped find under org_a AND org_b with exact id sets, by-id null, count 2, scoped update and delete leave the row named Orphan, unscoped read still sees it); the union pin now passes ['org_a','org_b'] and asserts the full union minus g1 (nit: the update/delete pin swallows the call's result and asserts the stored row — the row is the fact). sql-driver-tenant-scope-read-doors.test.ts: p1 leaves every scoped expectation, an explicit not.toContain('p1') pin is added, and the unscoped window case still expects ['a1','a2','b1','b2','p1'] (:215-218) — both sides held. sql-driver-21227-create-answers-stored-row.test.ts: the new mixed-batch case asserts the returned pair and that batch[1] equals the stored row. turso-local-remote-tenant-scope-parity.test.ts: orphan_1 moves from IN_SCOPE to OTHER_IDS, the aggregate sum drops the 1000, groups must not contain null; the both-faces-equal script inherits. turso-remote-inherited-members.test.ts: t3 re-stamped org_c, scoped distinct expects ['A']; the NULL row there was incidental (the scoped-distinct rule is pinned in read-doors) and the remote-face refusal pin is unchanged. tenant-audit-update-delete-half-repairs.test.ts: the "legacy" row is now really organization-less (seeded with no tenant and bypassTenantAudit; the old seeding under tenantId: 'org_A' was stamped by inject, so the old "stays reachable" pin was vacuous — a correct finding, fixed in scope); the pin asserts scoped update and delete leave it pending; the walled D2 leg moves the reach assertions into a new pin that asserts notFound refusals and untouched rows. rule-criteria-org-scope.test.ts: granted is ['deal_a1'], not.toContain('deal_p1'), matchedRecords 1; the platform-global rule's unscoped sweep is unchanged. record-share-organization-stamp.test.ts: the ORG_A rule grants deal_a1 only; the reconcile case flips from "aborts with RECORD_NOT_FOUND" to "completes", and the final set omits deal_a2 — the stale rule-B row IS revoked, so the pass now reaches its revoke loop: a stronger pin on designed behaviour. share-link-organization-stamp.test.ts: case 2 reaches the organization-less record through { isSystem: true }, which is right because the service reads under the bare SYSTEM_CTX for system callers (share-link-service.ts:72, :606), a context with no tenant and so no driver scope; the new pin holds a session acting in ORG_B to FORBIDDEN 403 with no row minted. Lifecycle (4 files): every organization_id partition expects the bare $nin; the sys_audit_log fixtures in lifecycle-service.test.ts are a test object WITH the column and are re-stamped (org_plain, and other-200d keeps the global-pass witness falsifiable); the attribution suite's helper branches by column, keeps the NULL arm on tenant_id, and its control "the ledger WITH the injected column partitions on it" expects the bare $nin.

6. Clause-② and semver — no (narrowing) is right; minor with BREAKING is right. Pure narrowing: the accept set of every tenant-scoped read door (find, count, aggregate, distinct, window read, update and delete predicates and their read-backs) shrinks by the organization-less rows, and the lifecycle global pass on organization_id shrinks the same way; the read-back guard changes no answer. Nothing widens: no export, type, error code or schema key is added or renamed (TenantPartition.nullIsLive is a non-exported interface field, globalPassSelector is module-private), so the claim line's yes (the dev re-measures) was correctly re-measured to no (narrowing) — yes is a widening of a published payload or accept set (scripts/check-changeset-no-major.mjs header; scripts/pm/clause2-line.mjs:244 fixes the spelling; scripts/pm/record-recognisers.mjs:156 maps it to this tier). Level: AGENTS.md:1088 — (narrowing) is BREAKING; ADR-0087 §launch-window as amended by #18003 — pre-GA a retirement ships minor carrying the BREAKING banner and an ADR-0087 disposition, and an npm major is never a side effect of one card; check-changeset-no-major.mjs refuses major. The changeset carries driver-sql: minor, objectql: minor, the BREAKING banner, the adr-0087: not-required (no-migration-prescription) marker (correct: no key is removed), Before/Now/Unchanged, who is affected and the remedy (run the ceremony or stamp the row); driver-turso is correctly absent (docblocks only). Same shape as the 8b precedent .changeset/15212-8b-walled-platform-insert-refused.md. Check Changeset is green on the head.

7. Merge hygiene — this part alone. git merge-base origin/main 2ee8e8d87 is 307ce4c4b42503347d7f85825f572e4ca058b05f; git diff --stat 307ce4c4b...2ee8e8d87 equals git diff --stat 307ce4c4b 2ee8e8d87 (19 files, +332/-241), so nothing from the base side rides in; 12d14765c (#24681) is an ancestor of the head. The branch's own commits are 30473b42a..9987cf807 plus merges of the ceremony branch (81ebc64bb) and of main (0012a4c6d, 1f360ec75, 2ee8e8d87). check-governed-merges reports 0 of 19 on the register (dev), and no docs/adr/**, schema-differ, COALESCE or __global__ path moves. origin/main has since advanced to ddb3360a4 (55 files beyond the base); the claim's landing rule says the branch merges main again before it lands — see ③.

② Semver level

minor on @objectstack/driver-sql and @objectstack/objectql, BREAKING banner present, Clause-②: no (narrowing) at the start of a line in both the PR body and the changeset, ADR-0087 disposition stated. Consistent with the diff (a read-scope narrowing, no removed or renamed authorable key, export or error code). Never major: correct under ADR-0087's level rule and check-changeset-no-major.mjs.

③ Boundary flags

  • Dev open question 1 (Clause-②): A — keep no (narrowing), minor, BREAKING noted. The change narrows an accept set and widens nothing (judgment 6).
  • Dev open question 2 (lifecycle NULL disjunct): A — keep it on the attribution partition only. tenant_id is not a tenant column (judgment 3); dropping it would strand deployment-level ledger rows whenever a tenant override exists.
  • Dev open question 3 (file surface): A — accept. The additions are the pins that asserted the retired reach (plugin-sharing 3, service-storage 1, turso fixture re-stamp, driver-sql on MySQL: create() answers the insert id (0) instead of the inserted record, so sign-up answers 400 FAILED_TO_CREATE_USER, the dev admin seed fails and no user can sign in #21227 case) and one same-function guard that the retired arm requires (judgment 4). No other runtime file moved; the Turso hunks are docblocks.
  • Residual R1 — the D8 gate's own text, and its missing refusal (pre-existing, recommended in this PR or the 8c closing part). scripts/check-tenant-chokepoint.mjs still describes "the NULL-org platform-row rule (Fresh objectstack dev boot: tenant admin sees ZERO rows in sys_position / sys_permission_set / sys_business_unit over REST (Setup Access Control renders empty) #2734)" as what the chokepoint owns (:11-12) and its failure remedy tells a dev "Do not re-derive the predicate locally — a bare equality silently drops NULL-org platform rows (Fresh objectstack dev boot: tenant admin sees ZERO rows in sys_position / sys_permission_set / sys_business_unit over REST (Setup Access Control renders empty) #2734)" (:686-697): after this PR that remedy instructs the opposite of the chokepoint's contract. And D8's sentence "check-tenant-chokepoint refuses an IS NULL arm on a tenant column" describes a refusal the script does not implement (it checks routing through applyTenantScope only; no whereNull/IS NULL test exists in it, before or after this PR). The arm's return is held by the pins (dev's ablation: 4 red), so no enforcement is lost here, and the file is tooling text, not a published surface or governed path — so not blocking. Fix: restate the two texts to the 18.x rule, and add the refusal D8 promises (a check over applyTenantScope's body for whereNull/orWhereNull/IS NULL on the resolved tenant field, with a self-test case), so the rule and the gate that enforces it land as one.
  • Residual R2 — the other 8c readers (not this part). The explicit NULL-organization filters named in judgment 1 (approval-service.ts:2761, sharing-rule-service.ts:955, :1218, organization-scope.ts residue branch and delegated-admin-gate.ts:1294) and the plan's grantAppliesInTenant / criteriaContext are 8c's "authz NULL readers"; under a wall their NULL half is now dead code. For the 8c closing part.
  • Residual R3 — text that still names the retired arm as current (not this part; a sweep for the 8c closing part). delegated-admin-gate.ts:1267-1268, organization-scope.ts:37-38, :112-113, sharing-rule-service.ts:1797-1800, service-messaging/http-outbox.ts:176-179, memory-http-outbox.ts:207, sql-http-outbox.ts:197, service-storage/metadata-store.ts:181, backfill-sys-file-organizations.ts:15, plugin-sharing/backfill-sys-record-share-organizations.ts:17, runtime/domains/packages.ts:2693 (dev-noted), and packages/spec/src/api/events.zod.ts:265, :270 (published doc text, the webhooks env-wide row — 7c's dual read).
  • Residual R4 — landing order. origin/main is 55 files past the base; the branch merges main again before it lands (claim's serial constraint, 7b-3 edits sql-driver.ts in parallel). Dogfood shards and the 18.x harness do not run on pull_request; R1-R4 and the harness on the merged tree rest on the dev's local runs (R1 health/sign-in/create, R2+R3 12/12, R4 isolated boot + 7/7 e2e) and are to be re-stated on the final merged head. A walled group boot by hand was not measured (dev's not_measured).
  • CI on the head (read 2026-10-20 ~08:55Z, after every run completed): 41 check runs, 34 success, 7 skipped, 0 failed. Green include Build Core, Test Core 1/6-6/6, every Type Check and Lint job, Lint · PM dispatch-gates, Check Changeset, Check PR Size, Governed Surface Queue Guard, the single-writer and same-issue claim guards, Part-of PR must not also close its card, Dogfood Regression Gate (the filter job). Skipped by design on pull_request: Build Docs, Temporal Conformance, the Dogfood Regression Gate shards, Dogfood Verify CLI, Console Pin Gate, Packed-tarball smoke, 18.x upgrade harness — carried as R4 above. No failed required check.

Implemented-by: claude/issue-24583-7a1-driver-null-arm
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 09:30
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 09:30
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 7c2eab9 Oct 20, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24583-7a1-driver-null-arm branch October 20, 2026 10:06
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

Development

Successfully merging this pull request may close these issues.

2 participants