Skip to content

feat(scripts): check-tenant-chokepoint refuses an IS NULL arm on a tenant column, and shipped texts stop describing the retired arm (18.x step 7, part 7z) - #24880

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-24583-7z-closing
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-24583-7z-closing

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #24583

Clause-②: no (the gate refusal narrows what a contributor may write in three driver files; every other change is text. No accept set, served body, error code or runtime behaviour moves)

This is step 7's closing part (7z). It leaves #24583 open for the parts still in flight: 7a-2, 7b-0 and 7b-1+7b-2.

What changed

1. The D8 gate: check-tenant-chokepoint now refuses an IS NULL arm on a tenant column

ADR-0131 D8 says "check-tenant-chokepoint refuses an IS NULL arm on a tenant column." Before this PR the gate did not do that. Its header and its failure text also still taught the retired NULL-org platform-row arm as the rule.

  • Texts restated. The header and the unscoped-door failure text now name the one predicate: tenant_column = :tenant, or IN (:tenants) under the group posture.
  • Refusal implemented. It scans every class member and every module-level function in the three SqlDriver-family files.
    • A tenant column reference is one of three things:
      • a local bound to this.resolveTenantField(...), computeTenantField or computeAndRecordTenantField;
      • anything named tenantField (a local, a parameter or a member);
      • the literal 'organization_id'.
    • Refused spellings:
      • whereNull / orWhereNull / andWhereNull on such a reference;
      • a where / orWhere / andWhere with a null comparand, two-argument or three-argument;
      • the object form with a null value;
      • a raw fragment (whereRaw, orWhereRaw, andWhereRaw, havingRaw, raw) whose literal text says IS NULL and that names the column by substitution, by binding or literally.
    • IS NOT NULL is never refused.
    • Tenant bindings are scoped to their outermost container. So the filter compiler's own whereNull(field) on a plain column stays silent.
  • Exemptions: none. NULL_ARM_EXEMPT is empty, and main needs no entry.
    • The sys_audit_log.tenant_id attribution partition that LifecycleService reads is a D7 field, not a D8 tenant column. It is also outside the scanned files. The header names it.
  • Floor. TENANT_PREDICATE_FLOOR = 3 (4 on main). A matcher that stops recognising the tenant column exits 2 instead of reporting clean.
  • Self-test. A second battery, D8 null-arm rule, is pinned at 22 cases: 11 refused spellings, 6 silent counterparts, 2 floor-counter cases and 3 exemption-channel cases. The roster floor goes from 1 to 2.

2. Text drift

Comments in 15 runtime files, plus one runtime warning, said in the present tense that the driver or engine still reads organization-less rows. Each is now correct. Where code was written against the old arm, the text says so.

  • spec events.zod.ts, driver.zod.ts
  • runtime domains/packages.ts. sys_metadata has no organization column (D6/D7).
  • plugin-security organization-scope.ts, delegated-admin-gate.ts
  • plugin-sharing backfill-sys-record-share-organizations.ts
  • plugin-webhooks auto-enqueuer.ts
  • objectql platform-object-tenancy.ts. This is a runtime evidence string.
  • service-automation runtime-identity.ts, builtin/http-nodes.ts. The latter is the runtime [http] warning.
  • service-messaging http-outbox.ts, memory-http-outbox.ts, sql-http-outbox.ts
  • service-storage backfill-sys-file-organizations.ts, metadata-store.ts

3. Docs

  • deployment/cli.mdx (the os migrate duplicates table) and deployment/seed-tenancy-repair.mdx:
    • The live condition is now described as the record of which counter minted the duplicates. It is no longer "about to mint more". Since 7b-3, the counter refuses an organization-less row on a tenant table wherever the organization object is registered.
    • The colliding rows "stay findable by this report". The old text said "visible".
  • protocol/objectql/schema.mdx: wherever the organization object is registered, a tenant table holds no organization-less row. On a single-organization deployment, rows are stamped with the Default Organization.
  • unique/legacy-organization-composite: the os explain text in rule-explanations.ts and the rule docblock in data-model-rules.ts now carry the "wherever the organization object is registered" hedge.

4. Spec TSDoc

SharingRuleRow was described as a "Stored shape … 1-to-1 to sys_sharing_rule". It is now described as a stored row or a declared rule's projection, and deploymentWide is named as the one member with no column.

Changeset: .changeset/24583-7z-closing-d8-texts.md, patch for the ten packages whose shipped text changed. No existing changeset is touched.

Proof

All runs below are on head e5f47d90d unless marked. That head is main 81120ca merged in with a merge commit.

  • Gate self-test.
    • node scripts/check-tenant-chokepoint.mjs --self-test exited 0: "6 reporting shape(s), 6 silent counterpart(s) … D8 null-arm rule: 11 refused spelling(s), 6 silent counterpart(s)."
  • Ablation, on a scratch copy. The copy is a git-initialised git archive HEAD of scripts/ plus the three driver files.
    • node scripts/ablation-replace.mjs replaced return builder.where(field, String(tenantId)); in applyTenantScope with a grouped callback that compiles where(field, String(tenantId)).orWhereNull(field), the v17 arm. The anchor count went 1 to 0, the injection count was 1, and the blob changed from 206bca5f0bce to f741fbd69db5.
    • Under that mutation the gate exited 1 with: "1 IS NULL arm(s) on a tenant column … sql-driver.ts:15664 applyTenantScope() — orWhereNull(tenant column)".
    • Restored: the blob equals HEAD (206bca5f0bce) and git diff HEAD is empty. The scratch copy was green (exit 0) before the mutation.
  • Gate green on main plus this diff.
    • pnpm check:tenant-chokepoint exited 0: "24 getBuilder() binding(s) across 3 file(s) … no IS NULL arm on a tenant column (ADR-0131 D8)."
    • Exemptions: none.
  • Spec build and generated artifacts (pre-merge head cd794f6).
    • pnpm --filter @objectstack/spec build exited 0.
    • check:generated exited 0: "All 14 generated artifacts are up to date". Nothing was regenerated.
  • Spec suite (pre-merge head): 703 files passed, 21360 tests passed.
  • Lint suite (pre-merge head): 150 files passed, 6988 tests passed. The first attempt failed on a missing @objectstack/formula dist, before its dependency closure was built. It was re-run green after pnpm --filter '@objectstack/lint...' build.
  • Full build on the merged head: pnpm build --concurrency=2 passed 76/76 tasks.
  • R1, a fresh boot (pnpm dev -- --fresh -p 38723, merged head): /api/v1/health returned 200 and POST /api/v1/auth/sign-in/email returned 200. The boot log had zero error lines. The server was torn down by its PID.
  • node scripts/pm/dispatch-gates.mjs --commands derived 134 commands, the same list on the pre-merge and merged heads.
    • Each command was run with its exit code captured before any pipe.
    • 128 exited 0 on the first pass, on the pre-merge head 8c68d506.
    • 6 were prerequisite refusals: check-engine-split-ratio (exit 2, shallow clone), check-plugin-teardown-shape --self-test (exit 3, shallow clone), and check:skill-examples, check:dual-build-cjs-loads, check:i18n and check:lean-entry-closure (exit 3, no build).
    • After git fetch --shallow-since=2026-07-15 and the full build, all 6 re-ran on the merged head and exited 0.
    • --ran with cmd :: exit N records: "134 derived famil(ies) accounted for — 134 run, 0 NOT-MEASURED (a DERIVED zero …)".
  • node scripts/pm/check-governed-merges.mjs --branch claude/issue-24583-7z-closing exited 0: "0 of 23 path(s) hit the register … NOT governed".
    • scripts/ is not a governed surface. The size is 741 changed lines, under 3000.
  • Not measured locally (left to CI): per-package typecheck (comment and string changes only), test:repo, and the CI-only jobs that --commands names.

Acceptance notes

Found along the way and left as they are. None is fixed here, because the claim's file surface is texts plus the gate.

  • memory-http-outbox.ts redeliver() still admits an organization-less row for every tenant: its predicate was written to mirror the v17 SQL term. The SQL driver no longer does this. The comment now says that the two implementations disagree; the code is unchanged. Reported to the seat.
  • organizationOutOfWriteReach (service-storage/src/metadata-store.ts) still answers "in reach" for an organization-less row, which D8 put out of reach. Under ADR-0131 a walled deployment holds no such row once certified. The comment says the branch predates D8.
  • applySysRecordShareOrganizationBackfill threads tenantId, so under D8 its update no longer reaches an organization-less row. No runtime path calls it; the 18.x ceremony's attribution fate stamps these rows now. The comment says so.
  • Test-file comments still mention the driver's "NULL-tolerant arm": record-share-tenant-wall.test.ts and backfill-sys-record-share-organizations.test.ts. They are not runtime files, so they were left.
  • core/src/security/resolve-authz-context.ts ~795 ("a null-org row is global") is an authorization reader. That is 7a-2's surface, so it is left to 7a-2.
  • metadata-protocol/src/protocol.ts still says saveMetaItem writes organization_id = NULL (~24069, ~29060), but sys_metadata has no organization column. This is a different drift class from the driver-reads-NULL one, and it is left.

Generated by Claude Code

…nant column (ADR-0131 D8)

The gate's header and failure text still taught the retired NULL-org
platform-row arm as the rule. Restate both, and implement D8's refusal:
whereNull/orWhereNull/andWhereNull, a null comparand, the object form and a
raw IS NULL fragment on a tenant column reference anywhere in the three
SqlDriver-family files is refused. A recognised-predicate floor keeps the
rule from passing vacuously. No exemption is needed on main.

Claude-Session: https://claude.ai/code/session_017ae4eMXWeERGrG6heTbwBX
Co-authored-by: Claude <noreply@anthropic.com>
… organization-less rows

ADR-0131 D8 retired the driver's OR organization_id IS NULL arm, but the
comments of fifteen runtime files (and one runtime warning) still described
it as the live tenant predicate. Restate each: the tenant term is the
equality (or IN under group) alone, an organization-less row on a tenant
table is reached by no tenant-scoped read, and where code was written
against the old arm the text now says so.

Claude-Session: https://claude.ai/code/session_017ae4eMXWeERGrG6heTbwBX
Co-authored-by: Claude <noreply@anthropic.com>
…e legacy-composite rule text and SharingRuleRow for ADR-0131

- cli.mdx / seed-tenancy-repair.mdx: the live condition is the record of
  which counter minted the duplicates; since the counter refuses an
  organization-less row on a tenant table wherever the organization object
  is registered, it no longer forecasts more.
- schema.mdx: a tenant table holds no organization-less row wherever the
  organization object is registered.
- unique/legacy-organization-composite: carry that same hedge.
- SharingRuleRow: deploymentWide has no sys_sharing_rule column; the
  interface is no longer a 1-to-1 stored shape.

Claude-Session: https://claude.ai/code/session_017ae4eMXWeERGrG6heTbwBX
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:data 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 10 package(s): @objectstack/lint, @objectstack/objectql, @objectstack/plugin-security, @objectstack/plugin-sharing, @objectstack/plugin-webhooks, @objectstack/runtime, @objectstack/service-automation, @objectstack/service-messaging, @objectstack/service-storage, @objectstack/spec, touching 13 documentable anchor(s). ⚠️ 6 changed file(s) yielded no anchor (packages/lint/src/data-model-rules.ts, packages/plugins/plugin-security/src/organization-scope.ts, packages/plugins/plugin-sharing/src/backfill-sys-record-share-organizations.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/webhooks.mdx (via DataEventSchema (symbol, a top-level const))
  • content/docs/data-modeling/objects.mdx (via sys_record_share (symbol, a field of const object PLATFORM_OBJECT_TENANCY))
  • content/docs/permissions/delegated-administration.mdx (via DelegatedAdminGate (symbol, a top-level class))
  • content/docs/permissions/permissions-matrix.mdx (via sys_record_share (symbol, a field of const object PLATFORM_OBJECT_TENANCY))
  • content/docs/permissions/sharing-rules.mdx (via sys_record_share (symbol, a field of const object PLATFORM_OBJECT_TENANCY))
  • content/docs/permissions/system-context.mdx (via sys_record_share (symbol, a field of const object PLATFORM_OBJECT_TENANCY))
  • content/docs/plugins/packages.mdx (via sys_record_share (symbol, a field of const object PLATFORM_OBJECT_TENANCY))
  • content/docs/protocol/objectql/security.mdx (via sys_record_share (symbol, a field of const object PLATFORM_OBJECT_TENANCY))

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

  • content/docs/releases/v13.mdx (via DelegatedAdminGate (symbol, a top-level class))
  • content/docs/releases/v14.mdx (via sys_record_share (symbol, a field of const object PLATFORM_OBJECT_TENANCY))
  • content/docs/releases/v15.mdx (via sys_record_share (symbol, a field of const object PLATFORM_OBJECT_TENANCY))
  • content/docs/releases/v16.mdx (via sys_record_share (symbol, a field of const object PLATFORM_OBJECT_TENANCY))
  • content/docs/releases/v17/17-1.mdx (via sys_record_share (symbol, a field of const object PLATFORM_OBJECT_TENANCY))
  • content/docs/releases/v17/17-3.mdx (via sys_record_share (symbol, a field of const object PLATFORM_OBJECT_TENANCY))
  • content/docs/releases/v17/index.mdx (via sys_record_share (symbol, a field of const object PLATFORM_OBJECT_TENANCY))

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
  • 6 changed file(s) yielded no anchor (packages/lint/src/data-model-rules.ts, packages/plugins/plugin-security/src/organization-scope.ts, packages/plugins/plugin-sharing/src/backfill-sys-record-share-organizations.ts, …) — pages documenting those are invisible to this run
  • 3 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 — 153 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 b1676441ba59cc32682ffd41e63e74474ad7fa42 → packageMentionDocs.

Which tree this was computed on

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

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

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

…nger lets a tenant replay an organization-less row

MemoryHttpOutbox.redeliver() still carried the v17 NULL arm: a row with no
organization passed every tenant's wall. ADR-0131 D8 rules a NULL arm out
in every layer, and SqlHttpOutbox (through the driver's applyTenantScope)
already answers RESOURCE_NOT_FOUND there. Match it, and flip the pin that
held the old arm into one that refuses it.

Claude-Session: https://claude.ai/code/session_017ae4eMXWeERGrG6heTbwBX
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the tests label Oct 20, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat note on the PR body · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-20T13:05Z

The dev cannot PATCH the body, so the seat records the correction here. Since commit 2db73df02, this PR carries one behavioural fix on top of its texts. The body's Clause-② parenthetical and its line "No accept set, served body, error code or runtime behaviour moves" are stale. They read as follows:

  • Clause-②: no (one fix and texts). The in-memory HTTP outbox's redeliver() now refuses an organization-less row to a tenant-scoped caller, as SqlHttpOutbox already did (ADR-0131 D8). No shipped composition constructs MemoryHttpOutbox; it is constructed only in tests.
  • The Acceptance-notes bullet on memory-http-outbox is a "Fixed here" item.

Withdrawn from this part: the plugin-auth organization-less readers (last-admin-guard.ts anchorOwnerTest, ensure-default-organization.ts). They are live contracts, not dead readers, and are filed as #24883.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e2b88d857238a1dae999df2c271a73cadae160ef
Local-runs: none
Scope: packages/spec/src/api/events.zod.ts, packages/spec/src/contracts/sharing-service.ts, packages/spec/src/data/driver.zod.ts, scripts/check-tenant-chokepoint.mjs, packages/lint/src/data-model-rules.ts, packages/lint/src/rule-explanations.ts, packages/objectql/src/tenancy/platform-object-tenancy.ts, packages/plugins/plugin-security/src/delegated-admin-gate.ts, packages/plugins/plugin-security/src/organization-scope.ts, packages/plugins/plugin-sharing/src/backfill-sys-record-share-organizations.ts, packages/plugins/plugin-webhooks/src/auto-enqueuer.ts, packages/runtime/src/domains/packages.ts, packages/services/service-automation/src/builtin/http-nodes.ts, packages/services/service-automation/src/runtime-identity.ts, packages/services/service-messaging/src/http-outbox-organization.test.ts, packages/services/service-messaging/src/http-outbox.ts, packages/services/service-messaging/src/memory-http-outbox.ts, packages/services/service-messaging/src/sql-http-outbox.ts, packages/services/service-storage/src/backfill-sys-file-organizations.ts, packages/services/service-storage/src/metadata-store.ts + packages/drivers/driver-sql/src/sql-driver.ts, packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.ts, packages/drivers/driver-turso/src/turso-driver.ts, packages/objectql/src/lifecycle/lifecycle-service.ts, packages/objectql/src/tenancy/system-write-organization.ts, packages/services/service-messaging/src/messaging-service-plugin.ts, packages/services/service-messaging/src/index.ts, packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts, packages/spec/src/security/tenancy-posture.ts, packages/spec/scripts/check-generated.ts, package.json, .github/workflows/lint.yml, scripts/pm/clause2-line.mjs, scripts/pm/record-recognisers.mjs, docs/adr/0131-total-organization-ownership-no-null-organization-id.md

Base: merge-base b1676441ba59cc32682ffd41e63e74474ad7fa42, 24 files, +635/-143. Read-only: the diff, the card (claim 6289788602, dev report 6290925298, seat note 6291547374 on the PR), the head's check-runs. The tree decides the tier: packages/spec/src/** is a suspect glob (dispatch-gates.data.mjs:1248), so the three spec paths are schema hits and the face is at CONTRACT_REVIEW_TIER whichever way the declaration is read; the Scope line is widened to every non-prose path of the diff, as the narrowing face's floor asks.

① Derived judgments

1. The gate — scripts/check-tenant-chokepoint.mjs — executes ADR-0131 D8, with no new gate. D8 (ADR-0131:585-586) names this gate and this refusal; AGENTS.md's default-no for new gates does not apply.

  • Recall over the three SqlDriver-family files. The rule (nullArmOf, :391-422) refuses each Knex spelling that compiles to IS NULL on a tenant column reference: whereNull / orWhereNull / andWhereNull (:395); where / orWhere / andWhere with a null comparand, two- or three-argument (:397); the object form with an identifier, string or computed tenant key (:400-409); and whereRaw / orWhereRaw / andWhereRaw / havingRaw / raw whose literal says IS NULL and names the column by substitution, binding or literal (:411-419). A tenant column reference (isTenantRef, :357-370) is a local bound to this.resolveTenantField / computeTenantField / computeAndRecordTenantField, anything named tenantField, or the literal 'organization_id', unwrapped through parentheses, !, as and String(). Those are the only three ways the scanned files name the column: sql-driver.ts binds the resolver at :7657, :8932, :8968, :9721, :12251, :12348, :13874, :15637, :15709, :15747, :17769 and names tenantField as a parameter (:7997) and a member (:8368); no other tenant-column name occurs in the three files (grep for tenantColumn / orgField / organizationField / tenancyField is empty), and sqlite-wasm-driver.ts and turso-driver.ts carry no NULL spelling and no tenant predicate at all. Bindings are scoped to the outermost container (nullArmsOf, :455-459), so nested closures are covered. Two blind spots, both outside the drivers' spellings: an element-access verb (qb[...'orWhereNull' : 'whereNull'](field), as the filter compiler writes it at :16683, :17595, :17600 on a filter field) is not a property access and is never examined (:392); a tenant column handed to a helper under a parameter not named tenantField is unseen, which the header names (:145-148).
  • Precision on non-tenant columns. The live NULL spellings in sql-driver.ts are the filter compiler's — whereNull(field) (:16689, :17113), the null-safe negative raw IS NULL OR (:16311) and the distinct-from raws (:17881-17918) — none on a tenant reference, and the silent counterparts at :897-912 mirror exactly those shapes. The gate ran green on the merged tree in the Lint · CI, release & engine gates lane. One over-refusal in principle: where(tenantRef, 'is not', null) and where(tenantRef, '=', null) would be refused because :397 does not read the operator, while header :136 says IS NOT NULL is never refused. Neither spelling exists in the files; the direction is safe.
  • D7 partition. NULL_ARM_EXEMPT (:319) is empty and needs no entry: the surviving NULL disjunct is globalPassSelector in packages/objectql/src/lifecycle/lifecycle-service.ts:272-276, which keeps { [column]: null } only for ATTRIBUTION_PARTITION_COLUMNS (sys_audit_log.tenant_id, an ADR-0131 D7 attribution field, ADR:509-511). It is outside SCAN_FILES (:168-172), so the header's statement (:138-143) is exact.
  • Floor. TENANT_PREDICATE_FLOOR = 3 (:220) has been 3 since the first commit 584b202; it never read 4. "4 on main" (:216) is the measured count: sql-driver.ts:8001 (where(tenantField, tenantId)), :8368 (where(reservation.tenantField, …)), :15652 (whereIn(field, …)) and :15664 (where(field, String(tenantId))), identical at the branch base df8bb40 and at the head; the subclasses add none. 7a-1 retired an orWhereNull arm, which TENANT_PREDICATE_CALLS (:310, where / whereIn) never counted, so no arm retirement moved the count. The floor sits one below the count the way DISCOVERED_FLOOR = 15 sits below 24 bindings: losing either half of isTenantRef (by-name or resolver-bound) drops the count to 2 and exits 2 (:1063-1072). Not a weakening.
  • Self-test and ablation. The D8 null-arm rule battery is 11 refused spellings (:811-875) + 6 silent counterparts (:880-913) + 2 counter asserts (:921-928) + 3 exemption asserts (:931-942) = 22, equal to the pinned floor (:632); the roster floor is 2 (:637). Refused case 1 is the v17 arm itself inside applyTenantScope. The ablation recorded in the PR body (the repo's scripts/ablation-replace.mjs, on main since c4b84ad, run on a scratch copy) put .orWhereNull(field) back beside sql-driver.ts:15664, where field is bound at :15637 by this.resolveTenantField(object) — the matcher's first form — and the gate reported that line and exited 1; the restore was blob-equal. The self-test and the ablation discriminate.
  • Wiring. package.json:206 runs --self-test then the scan; .github/workflows/lint.yml:4071-4075 runs pnpm check:tenant-chokepoint in lane lint-ci-engine (:3141, check-run Lint · CI, release & engine gates). The step's continue-on-error: true is the lane idiom (:151-153): the closing Lane verdict step (:4273-4278, scripts/ci/lane-verdict.mjs over toJSON(steps)) keeps the red. The lane completed success on this head; lint.yml has no paths filter.

2. The memory-http-outbox fix is correct and matches SqlHttpOutbox. MemoryHttpOutbox.redeliver (memory-http-outbox.ts:218-224) now refuses when options.tenantId !== undefined && row.organizationId !== options.tenantId, so an organization-less row (organizationId undefined) is RESOURCE_NOT_FOUND to every tenant-scoped caller and still reachable to a tenant-less one. SqlHttpOutbox.redeliver (sql-http-outbox.ts:510-521) answers the same two ways through engine.findOne({ where: { id }, tenantId }): the driver's applyTenantScope compiles organization_id = :tenant (sql-driver.ts:15664) with no NULL arm, and early-outs when tenantId is undefined; sys_http_delivery's organization_id is the kernel-provisioned tenant column (objectql registry.ts:598), so the scope applies. Pinned: the replaced case (http-outbox-organization.test.ts:173-190) asserts the HttpRedeliverError class and the RESOURCE_NOT_FOUND code, that the row is untouched (dead, attempts 1), and that a tenant-less replay still succeeds — strictly more than the old single success assertion. Composition: the only shipped construction is new SqlHttpOutbox(engine, …) at messaging-service-plugin.ts:441; new MemoryHttpOutbox( occurs in 35 test files and nowhere else; the class TSDoc (:26) says "for tests and single-process development". It is a public export (service-messaging/src/index.ts:166), which the changeset names.

3. Texts are accurate after 7a-1 (#24825), 7a-3 (#24828), 7b-3 (#24845) and 7c (#24836). Each of the 15 runtime files and the [http] warning (http-nodes.ts:277-335) now says an organization-less row is reached by no tenant-scoped read, which is applyTenantScope as it stands; organization-scope.ts:182-189's "check-tenant-chokepoint refuses it" becomes true in this same PR. backfill-sys-record-share-organizations.ts:259-264 says no runtime path calls the pass: grep finds no non-test caller. metadata-store.ts:787-790 says the organizationOutOfWriteReach branch predates D8 — honest, behaviour unchanged. cli.mdx:1801 and seed-tenancy-repair.mdx:305 describe 7b-3 in its own words: the counter refuses an organization-less row wherever sys_organization is registered (ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED), __global__ kept where none is. schema.mdx:608-621 still documents the COALESCE(organization_id, '__global__') bucket as the live mechanism and names its remaining uses (a composition with no organization object, pre-18.x residue) — no claim of NOT NULL at createTable (7b-0) or of plain indexes (7b-1+7b-2), which are not on main. The lint texts (data-model-rules.ts:596-602, rule-explanations.ts:2697-2703) carry the "wherever the organization object is registered" hedge and keep the COALESCE closure. SharingRuleRow (sharing-service.ts:477-484): deploymentWide is the one member with no sys_sharing_rule column; the other members map to the declared fields (sys-sharing-rule.object.ts:95-267, criteria lifted from criteria_json :141) and the system id / created_at / updated_at. check:generated: the diff changes no .describe() string; the replaced TSDoc strings survive only in CHANGELOGs and api-surface/contracts.json carries names, not comments, so nothing regenerates; the dev measured 14 artifacts current and CI's reconcile step passed.

4. Changeset .changeset/24583-7z-closing-d8-texts.md. The ten patch entries are exactly the ten packages with a changed file (names verified against each package.json; scripts/, content/docs and the changeset itself publish nothing). patch is right: one bug fix in a released package (AGENTS.md:1085) plus shipped text. Clause-②: no with a free parenthetical reads as no with no arm on both carriers (clause2-line.mjs:286-293 flags only a word of the widen/narrow family; "one" and "the" are not). No (narrowing) arm is right: the gate narrows what a contributor may write in repo scripts, not a published accept set, and the outbox fix conforms a documented test double to the IHttpOutbox.redeliver / RedeliverOptions contract (http-outbox.ts:297-301, "threaded to the driver as DriverOptions.tenantId"), whose narrowing 7a-1 declared no (narrowing) and shipped. The PR body's parenthetical is stale and corrected by the seat's note 6291547374. No other pending changeset is in the diff; docs/adr/**, CHANGELOGs and consumed changesets are untouched.

5. Merge hygiene. Two merges of main (e5f47d9, e2b88d8), both clean: for each, the file set of diff(main parent, merge) equals diff(merge-base of the parents, branch parent). The union of the six non-merge commits' files equals the 24 diff paths. chore: version packages (#24524) (06cab48) is an ancestor of the merge-base b167644, so it contributes nothing to git diff b1676441b e2b88d857. The PR diff is this part alone.

CI on the head. 41 check-runs, 0 failed (30 success, 6 skipped); Test Core (1/6) to (5/6) still in progress at the final poll; Test Core (6/6), Type Check · workspace, Check Changeset, Governed Surface Queue Guard and every Lint · lane completed success. Nothing blocking; the Test Core shards must finish green before the queue lands it.

② Semver level

patch × 10 — right. No published accept set, served body or error code of a shipped composition moves; the gate is repo tooling; the MemoryHttpOutbox change is a conformance fix to an already-declared narrowing, named for direct embedders in the changeset body.

③ Boundary flags

  • Withdrawn to measure(plugin-auth): the organization-less admin_full_access anchor in the last-admin guard and the pre-organization default-org bootstrap, after ADR-0131 step 7 #24883, correctly not in this diff: the plugin-auth organization-less readers (last-admin-guard.ts anchorOwnerTest, ensure-default-organization.ts), live contracts.
  • 7a-2 (feat(core,plugin-security)!: 18.x step 7 part 7a-2 — a grant applies only in its own organization; the organization-less grant readers retire #24872) carries core/src/security/resolve-authz-context.ts ~795 ("a null-org row is global").
  • Left as honest texts, behaviour unchanged (dev acceptance notes): organizationOutOfWriteReach (metadata-store.ts:784-791) answers in-reach for an organization-less row D8 put out of reach; applySysRecordShareOrganizationBackfill is dead code whose write no longer reaches a NULL row.
  • N (next text pass): events.zod.ts:259-262, unchanged and adjacent to this PR's hunk, still says every single-posture event is organization-less because postureStampsOrganization is false there; since ADR-0131 D9 (system-write-organization.ts:43-57) derives the Default Organization under single, and this PR's own schema.mdx:611-613 says so, the two texts now disagree.
  • N (next text pass): sys-sharing-rule.object.ts:99 field description "null = global" names the retired reading (7a-3's area).
  • N (gate, non-blocking): nullArmOf:397 reads no operator, so where(tenantRef, 'is not', null) would be refused against header :136; and element-access verbs are never examined (:392). Neither spelling is in the driver files. Fix: in the 3-argument form refuse only when the operator literal is is; name element access under "What it does NOT see".

Implemented-by: claude/issue-24583-7z-closing
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants