Skip to content

feat(objectql)!: under single, a platform object's organization-less system insert is stamped or refused (C8 stage 8b) - #24513

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-15212-8b-stamp-or-refuse
Oct 19, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-15212-8b-stamp-or-refuse

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #15212
Clause-②: no (narrowing)

C8 stage 8b, with stage 8c-T-α folded in: its commit 5d1b91e9b (5 grant rows in 4 test files stamped with the organization their context reads under) is this branch's first commit. Shaped by epic ruling record 6259899486 on #15194: 18.0 is fresh installs, single only, and 8b lifts the exemption under single only.

What changes

Engine.resolveSystemInsertOrganization (packages/objectql/src/engine.ts) used to return before it read the posture for every in-reach platform object outside the 9-entry admit list. Those system inserts landed with organization_id NULL under every posture.

  • Under single there is no platform-object exemption now. A system insert into a platform object with a tenant column that names no organization goes through the same ADR-0131 D9 decision every application object gets: one organization (the Default Organization) stamps it; zero or several refuse it with ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED. The census puts 35 objects in this newly reached set (44 in reach, minus the 9 admitted).
  • Under a walled posture (group / isolated) the design: isSystem 写入是否在租户审计控制范围内?——#13178 类级装置(A/B/C)的共同前置,从未被裁过 #13491 exemption stands, as a named posture condition with a comment citing 6259899486 (item 4). It is not a per-table list.
  • PLATFORM_OBJECT_TENANCY does not retire here. Two readers remain: the walled exemption above, and the engine's diagnostics-only bypassTenantAudit isSystem mute (buildDriverOptions, every posture). The ledger header now says this. It retires with its walled reader in 18.x (D13). The census tooling and its JSON are unchanged and green, and lint.yml is untouched.
  • Docblocks are updated in tenancy/platform-object-tenancy.ts and tenancy/system-write-organization.ts. packages/core/src/security/system-write-organization.ts needed no change: its decision function is unchanged, and editing its JSDoc would change published .d.ts text for no behavioural reason.

#14570 (sys_business_unit_member unadjudicated): under single its system-insert row is now stamped (pinned on the real showcase below). Under a wall it still lands NULL, because the exemption stands there in 18.0. So this is Part of #14570; the walled half rides 18.x.

Boot order (the main risk), measured

On a fresh single showcase boot (pnpm dev -- --fresh, SQLite) the boot is clean and the admin is seeded. Then:

  • the admin signs in;
  • the admin sends an invitation through POST /auth/organization/invite-member, which writes the invitation row and the identity mail;
  • the admin creates a record.

A direct DB census after these steps found 0 rows with organization_id NULL in every platform table that has the column, across sys_setting, sys_setting_audit, sys_member, sys_invitation, sys_email, sys_user_permission_set, sys_user_position, sys_audience_binding_suggestion, sys_inbox_message, sys_notification_receipt, sys_webhook, sys_business_unit, the approval family and sys_record_share. Every one was stamped with the Default Organization. No producer writes before the Default Organization exists, so no producer needed changing. (AuthPlugin.start() creates the Default Organization before the application plugins start, and the seed loader already stamps.)

Tests

New pins

Tests 8b broke, and how each was fixed. No pin of a NULL reader was rewritten, and no test was deleted or skipped.

  • Fixtures that hand-registered sys_organization / sys_user as bare schemas now declare them as the platform does (managedBy: 'better-auth', so no injected column). Otherwise the first organization's own insert was refused for want of an organization.

    • runtime/src/system-write-tenancy-autonumber-split.integration.test.ts
    • runtime/src/seed-tenancy-autonumber-split.integration.test.ts
    • service-messaging/src/notification-organization-follows-event.integration.test.ts
    • plugin-approvals/src/status-mirror-cascade.integration.test.ts
    • plugin-security/src/sys-audit-log-row-scope.test.ts
    • plugin-security/src/granted-by-writer-provenance.test.ts (plus the Default Organization row a single boot holds)
  • PIN fixtures that model organization-less legacy rows now land those rows through the driver, as the residue an older release left. These are global grants, the retired catalogue bucket, unscoped platform-admin holders and the pre-change ledger. The readers they pin still read NULL rows until 8c (18.x), so each assertion still pins what it pinned.

    • plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts and its plugin-auth copy
    • plugin-security/src/:
      • explain-enforce-parity
      • explain-removed-member-principal
      • explain-principal-person
      • explain-asker-gate
      • orgless-position-name-fold
      • catalog-reference-report
      • bootstrap-platform-admin-existing-holder-scan
      • controlled-by-parent-chain-middle-master
      • sys-audit-log-row-scope (LEDGER_BEFORE)
      • builtin-positions.boot (legacy sys_position rows)
    • plugin-auth/src/:
      • signup-existing-address-refusal
      • sso-register-platform-admin-gate
      • driver-fault-auth-log-carriers
      • scim-deactivation-reconcile-user
    • runtime/src/seed-tenancy-autonumber-split.integration.test.ts ([GUARD] platform residue)
  • One positive control rewritten to the ruled behaviour. In plugin-security/src/builtin-positions.boot.test.ts, the census control's organization-less sys_permission_set insert now reads back with the sole organization under single, and unstamped under the wall.

  • Incidental fixtures that only needed an owner now carry one:

    • plugin-audit/src/identity-objects-feeds-off.test.ts;
    • the SCIM rigs credential-at-rest-posture, scim-transaction-scope, scim-deactivation-reconcile-user and principal-less-producers-system-context. Their mint carries an organization; the verifier reads the digest alone. The plugin-auth engine pool refuses a database holding an organization row, so adding a Default Organization there was not an option.
  • Dogfood fixtures. The ablation below proved each of these red only because of 8b.

    • The Default Organization, asked of the tenancy service. This replaces an empty-where findOne whose swallowed refusal minted a second organization (the trap that sys_business_unit_member is unadjudicated in PLATFORM_OBJECT_TENANCY, so seed-replayed and system-written membership rows land organization-less #14570's remainder named):
      • showcase-scope-depth
      • showcase-scope-depth-fallback
      • showcase-scope-depth-write
    • A grant that carries the fixture organization its sessions are active in:
      • activity-withheld-update
      • identity-admin-fields-org-peer
    • Global grants that an organization-less principal must hold, landed beneath the engine:
      • write-door-unreadable-is-not-found
      • predicate-write-unreadable-not-matched
      • parent-derived-write-refusal-not-visible
      • sharing-rule-org-less-caller
    • Prechecks that resolved a principal with no organization now resolve in the organization a single session carries. The HTTP doors in the same files use real sessions, and they were green throughout.
      • wall-package-refusal
      • wall-package-doors-refusal
      • wall-authoring-trail-doors
      • wall-environment-metadata-refusal
      • catalog-activation-door
      • permission-set-clone-door
      • showcase-permission-zoo (explain is asked in that organization)
    • Pin rewritten to the ruled behaviour: single-tenant-identity-create. A system sys_team insert now takes the Default Organization; the pin used to assert NULL.

The plugin-auth edits are test-only. No plugin-auth source changed. 8b-0f (#24453) had merged into this branch's base before they were made.

Verification

Everything below is at head d1838bf96. Heavy runs went through os-verify-lock.sh.

Build and types

  • Build: pnpm build --concurrency=2 passed 76/76 tasks, after the second origin/main merge.
  • Typecheck: exit 0 in each of objectql, core, runtime, plugin-security, plugin-auth, plugin-audit, service-messaging, plugin-approvals and dogfood.

Tests

  • Unit and integration, in the downstream consumers of @objectstack/objectql, plus core. All 48 packages are green after the fixes (vitest per package). Among them:

    • core 2646
    • runtime 7455 (19 skipped)
    • plugin-security 3783
    • plugin-auth 3168
    • rest 6707
    • metadata-protocol 20674
    • service-analytics 4759
    • cli unit 4610

    One cli file (boot-preparation-parity) timed out at 5 s under parallel load; it passed alone, 37/37. The 18 originally red files re-ran green.

  • objectql: 446 files, 8369 tests. The only 4 reds were the pins this PR rewrites; those two files now pass 66/66.

  • Dogfood (whole suite, single and walled lanes): 2291 passed, 18 skipped, 0 failed (pnpm --filter @objectstack/dogfood exec vitest run).

  • Fresh showcase boot: clean, with the admin seeded and 0 NULL-organization platform rows (see above).

A1: the walled branch is unchanged

  • The engine's walled branch is the same expression as before.
  • The walled controls in both objectql pin files pass, unchanged in shape.
  • Every walled dogfood test passes. The ones that broke were rigs whose engine ran at the default single and simulated a wall only in plugin services.

Ablation

  • Tools: scripts/ablation-replace.mjs and ablation-dist-preflight.mjs, with a trap restore.
  • The mutation restored the exemption in every posture (!== 'single' became !== '8b-ablated'). Results:
    • objectql pins: 5 new cases went red.
    • dogfood pin: after a rebuild, with the marker proven present in dist, 3/3 went red.
    • restore: git diff HEAD empty, blob hash matched, rebuild exit 0, marker --absent exit 0.
  • The same ablation re-ran the 17 other dogfood files that 8b had turned red. All were green with the exemption back, so 8b is their only cause.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 80 commands, plus a 51-family artifact-roster block. All 128 ran, with exit codes captured before any pipe.

  • Green: every family not listed below. That includes:

    • check-platform-object-tenancy-census, check-tenant-audit-census and check-system-context-census;
    • check-adr-0087-registration (not-required (no-migration-prescription)) and check-changeset-no-major;
    • check:cross-package-test-inputs, check:test-source-alias, check:engine-double-contract, check:nul-bytes and check:type-check-debt.

    The families that read tests were re-run on the final head.

  • Shallow clone: check-engine-split-ratio --days 90 and check-plugin-teardown-shape --self-test refused on the shallow clone at first. Both are green after git fetch --shallow-since=2026-07-14.

  • NOT MEASURED locally: check-closing-target-claim, check-single-claim-paths and check-governed-queue-guard need PR context that only CI supplies. check-partof-closing-keyword was run against this body and is green.

Acceptance notes

  • mintScimConnectionCredential (plugin-auth) has no production caller. Under single an org-less mint is now stamped with the Default Organization. That is behaviour-neutral, because the verifier never reads the organization.
  • A showcase boot logs [sharing-rule] … expands to NO recipients for share_new_inquiries_with_field_ops with organization: null (the rule itself, under regime C). It is not a NULL membership row, and this stage does not touch it.
  • Under single, a non-system write with no tenantId also reaches the decision. The resolver runs for any context without an organization, not only isSystem, so such a write into a platform object is now stamped or refused as well. That is consistent with D9 ("every writer").

Generated by Claude Code

claude added 12 commits October 19, 2026 02:57
…nization their context reads under (C8 stage 8c-T-α)

Four grant rows whose test reads them under an active organization and
whose assertions never read the organization-less arm now carry that
organization, so they hold under today's resolver and once the NULL
grant arm retires. Assertions unchanged.

Claude-Session: https://claude.ai/code/session_01ATWiuWCaTBk1PD4VPimDsq
Co-authored-by: Claude <noreply@anthropic.com>
…s system insert is stamped or refused (C8 stage 8b)

`resolveSystemInsertOrganization` returned before the posture was read for
every in-reach platform object outside the 9-entry admit list, so those
inserts landed with a NULL organization_id under every posture. Under
`single` that exemption is gone: the insert takes the Default Organization,
or is refused at zero or several organizations (ADR-0131 D9). Under a walled
posture the #13491 exemption stands in 18.0, as a named posture condition
(epic ruling record 6259899486, item 4).

The ledger keeps two readers (the walled exemption and the diagnostics-only
isSystem mute), so it does not retire here; its header says so.

Claude-Session: https://claude.ai/code/session_01AZMXDVkiaxSpJkGXACuVwr
Co-authored-by: Claude <noreply@anthropic.com>
… inserts stamped or refused

Claude-Session: https://claude.ai/code/session_01AZMXDVkiaxSpJkGXACuVwr
Co-authored-by: Claude <noreply@anthropic.com>
…s, or carry their organization

Claude-Session: https://claude.ai/code/session_01AZMXDVkiaxSpJkGXACuVwr
Co-authored-by: Claude <noreply@anthropic.com>
…he engine, or carry an organization

Under single the engine now stamps or refuses an organization-less
platform insert. Fixtures that model rows an older release left
(global grants, the retired catalogue bucket, unscoped holders) write
them through the driver; fixtures that only needed an owner carry one.

Claude-Session: https://claude.ai/code/session_01AZMXDVkiaxSpJkGXACuVwr
Co-authored-by: Claude <noreply@anthropic.com>
… rows beneath the engine

Claude-Session: https://claude.ai/code/session_01AZMXDVkiaxSpJkGXACuVwr
Co-authored-by: Claude <noreply@anthropic.com>
…g an author writes is renamed

Claude-Session: https://claude.ai/code/session_01AZMXDVkiaxSpJkGXACuVwr
Co-authored-by: Claude <noreply@anthropic.com>
…e session carries, land global grants beneath the engine, or carry their owner

Claude-Session: https://claude.ai/code/session_01AZMXDVkiaxSpJkGXACuVwr
Co-authored-by: Claude <noreply@anthropic.com>
…eg asks a unit the user is not yet in

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

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 2 changed file(s) yielded no anchor (packages/objectql/src/tenancy/platform-object-tenancy.ts, packages/objectql/src/tenancy/system-write-organization.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/objectql/src/tenancy/platform-object-tenancy.ts, packages/objectql/src/tenancy/system-write-organization.ts) — pages documenting those are invisible to this run
  • 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 — 17 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 913fd6df8a2b3ec82612bf35dc40f4e062c3077f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 913fd6df8a2b3ec82612bf35dc40f4e062c3077f

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d1838bf965a4f84649019c1bff7e0f9f3e2f0b93
Local-runs: none
Scope: .changeset/15212-8b-single-platform-insert-stamped.md, packages/objectql/src/engine.ts, packages/objectql/src/tenancy/platform-object-tenancy.ts, packages/objectql/src/tenancy/system-write-organization.ts, packages/objectql/src/system-write-organization.test.ts, packages/objectql/src/tenancy-by-object-classification.test.ts, packages/core/src/security/resolve-authz-context.test.ts, packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts, packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts, packages/plugins/plugin-auth/src/tests/legacy-catalog-rows.testkit.ts, packages/plugins/plugin-auth/src/credential-at-rest-posture.test.ts, packages/plugins/plugin-auth/src/driver-fault-auth-log-carriers.test.ts, packages/plugins/plugin-auth/src/principal-less-producers-system-context.test.ts, packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts, packages/plugins/plugin-auth/src/scim-transaction-scope.test.ts, packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts, packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts, packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts, packages/plugins/plugin-security/src/tests/legacy-catalog-rows.testkit.ts, packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts, packages/plugins/plugin-security/src/builtin-positions.boot.test.ts, packages/plugins/plugin-security/src/catalog-reference-report.test.ts, packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts, packages/plugins/plugin-security/src/explain-asker-gate.test.ts, packages/plugins/plugin-security/src/explain-enforce-parity.test.ts, packages/plugins/plugin-security/src/explain-principal-person.test.ts, packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts, packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts, packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts, packages/plugins/plugin-security/src/sys-audit-log-row-scope.test.ts, packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts, packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts, packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts, packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts, packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts, packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts, packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts, packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts, packages/qa/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts, packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts, packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts, packages/qa/dogfood/test/showcase-single-platform-insert-default-organization.dogfood.test.ts, packages/qa/dogfood/test/single-tenant-identity-create.dogfood.test.ts, packages/qa/dogfood/test/wall-authoring-trail-doors.dogfood.test.ts, packages/qa/dogfood/test/wall-environment-metadata-refusal.dogfood.test.ts, packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts, packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts, packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts, packages/runtime/src/security/request-execution-context.conformance.test.ts, packages/runtime/src/security/resolve-execution-context.test.ts, packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts, packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts, packages/services/service-messaging/src/notification-organization-follows-event.integration.test.ts + packages/objectql/src/engine.ts (resolveEnginePosture :7039–:7047, probeInstallOrganizations :7111–:7141, buildDriverOptions :6858–:6950, the insert call site :16030–:16047), packages/objectql/src/index.ts :548–:572, packages/core/src/security/system-write-organization.ts (resolveSystemWriteOrganization :108–:126), packages/types/src/env.ts (resolveTenancyPosture :147–:163), packages/spec/src/security/tenancy-posture.ts (normalizeTenancyPosture :202), packages/plugins/plugin-security/src/security-plugin.ts (:1905–:1940, :4534, :4685), packages/plugins/plugin-auth/src/auth-plugin.ts (:826–:857), packages/plugins/plugin-auth/src/default-organization-invariant.ts (:80–:123), packages/plugins/plugin-auth/src/ensure-default-organization.ts (:610), packages/plugins/plugin-auth/src/auth-manager.ts (settleSelfRegistrationGrant :5681, landSelfRegistrationGrant), packages/plugins/plugin-auth/src/tenancy-service.ts (:162, :505), packages/core/src/kernel.ts (:560–:606, :1088–:1094), packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts (:223–:229, :298–:326), packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts (:109, :232, :256), packages/plugins/plugin-email/src/email-plugin.ts (:497–:498), packages/services/service-messaging/src/messaging-service-plugin.ts (:316–:404), packages/services/service-settings/src/settings-service-plugin.ts (:174–:177), packages/metadata-protocol/src/seed-loader.ts (:488–:716, :1312–:1318), packages/drivers/driver-sql/src/sql-driver.ts (auditMissingTenant :15678–:15705, isMultiTenantMode), scripts/platform-object-tenancy-census.json, scripts/check-changeset-no-major.mjs (:58), docs/adr/0131-total-organization-ownership-no-null-organization-id.md (D5, D8, D9, D11, D13), AGENTS.md (:460–:463, :1083–:1101)

① Derived judgments

(a) The lift under single.

  • The diff's only behavioural hunk is packages/objectql/src/engine.ts:7187: the exemption's early return isPlatformObjectOutOfTenantAuditScope(object) is now conjoined with this.resolveEnginePosture() !== 'single'. Under single an un-admitted platform object falls through to the chain every application object already takes: federated → out; no tenant column (resolveTenantFieldName) → out; every row already carrying the column → out; otherwise resolveSystemWriteOrganization (packages/core/src/security/system-write-organization.ts:108, unchanged): one organization → derived, threaded as DriverOptions.tenantId at engine.ts:16030–16047 so the driver's injectTenantOnInsert stamps the column; zero organizations with sys_organization registered → refuse no-organization; two or more → refuse ambiguous-organization. The refusal is SystemWriteOrganizationRequiredError (code ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED, status = 500, reason, organizationCount; tenancy/system-write-organization.ts:329–358), thrown before driver dispatch, so nothing is written. I re-derived the population from scripts/platform-object-tenancy-census.json (44 in reach) and the ledger at head (9 tenant-scoped entries, platform-object-tenancy.ts:145–253): 35 newly reached, as the PR says. Every one of them reaches D9's one decision.
  • The non-refusing paths that remain under single are all by construction, pre-existing, and not an exemption: (i) a context or row that carries an organization; (ii) no tenant column, federated, or tenancy.enabled: false; (iii) no-organization-object (engine.ts:7203–7215), a composition that registers no sys_organization at all, where the row lands NULL as before. (iii) is D9's own carve-out ("in a composition that registers the organization object"), unchanged by 8b and outside the ruled population (the ruling names the Default Organization, which such a composition cannot hold). No silent NULL path remains on a single install that mounts the auth plugin.
  • Posture read: resolveEnginePosture() (engine.ts:7039–7047) reads the injected provider first. That provider is the one plugin-security hands over with setTenancyPostureProvider, an accessor returning this.tenancyPosture (security-plugin.ts:1935–1936), and this.tenancyPosture is normalizeTenancyPosture(tenancy.posture) from the tenancy service (:1916–1919): the posture IN FORCE, a degraded wall resolving to single there. AuthPlugin.start() decides whether to create the Default Organization on the same service's posture (auth-plugin.ts:840), so the stamping side and the owner-creating side cannot disagree. Without a provider (a lean embedding with no SecurityPlugin) the engine reads resolveTenancyPosture() from @objectstack/types (env.ts:147: OS_TENANCY_POSTURE, else OS_MULTI_ORG_ENABLED), what the operator asked for. Same reading as the runtime: yes.
  • Unresolvable posture: resolveTenancyPosture() throws on an unrecognised OS_TENANCY_POSTURE; the engine catches and reads 'isolated' (engine.ts:7044–7046, documented at :7033–7038). Under 8b that lands on the walled branch: an admitted object is refused (walled-posture), an un-admitted platform object keeps the exemption and lands NULL, which is exactly its pre-8b behaviour, so no regression, and fail-toward-isolation is the right direction (guessing single would be the worse error). The branch is reachable only by an embedding that bypasses the CLI, since 18.0's operator boot path refuses an invalid posture (packages/cli/src/commands/serve.ts:6203). Acceptable; recorded.
  • Cost, non-blocking: the posture read now precedes the federated and tenant-field checks for every platform-namespace object, including ones with no tenant column. It is a provider call or an env read, never a query; the "never probes sys_organization for a write it does not judge" pin still holds (the walled branch refuses before probing).

(b) The walled branch.

  • Under group / isolated the predicate and the return at engine.ts:7187 are the same expression as before (isPlatformObjectOutOfTenantAuditScope(object) → return undefined). PLATFORM_OBJECT_TENANCY, its 9 entries, classifyPlatformObjectTenancy and isPlatformObjectOutOfTenantAuditScope are byte-unchanged (that file's diff is docblock only), and the core decision function's walled arm (posture !== 'single' → walled-posture refusal for an admitted object) is unchanged. Today's exemption is kept exactly, under the walled postures only.
  • It is a named posture condition, with the comment citing epic ruling record 6259899486 item 4 and the "never re-narrow it with a per-table list" warning (engine.ts:7176–7186). Not a per-table list.
  • No walled path changes behaviour. The pins hold the control: system-write-organization.test.ts (isolated / group → tenantId undefined) and tenancy-by-object-classification.test.ts (isolated / group un-admitted → undefined; admitted → refused). The single control that moved in system-write-organization.test.ts (['isolated','single'] → ['isolated','group'] plus a separate single DERIVES case) is the ruled rewrite, not a weakening.

(c) Boot order: a fresh single install boots.

  • AuthPlugin.start() calls ensureDefaultOrganizationExists under single (auth-plugin.ts:840–857); a failure throws and fails the boot. Its own writes cannot be refused: sys_organization is out of reach (managedBy: 'better-auth', no injected column; census reach: out), and the owner bind's sys_member row carries organization_id: defaultOrgId (ensure-default-organization.ts:610).
  • Every boot-time producer into the 35 that I could find runs at kernel:ready, which the kernel fires only after every plugin's start() (kernel.ts:560–606, Phase 2 then Phase 3): plugin-webhooks' bootstrapDeclaredWebhooks → sys_webhook (webhook-outbox-plugin.ts:223–229; SYSTEM_CTX insert at bootstrap-declared-webhooks.ts:256; failures warn-swallowed at :323), plugin-security's platform-admin bootstrap and org-admin backfill (security-plugin.ts:4534, :4685), service-settings' engine bind (settings-service-plugin.ts:177). plugin-email (email-plugin.ts:498) and service-messaging (messaging-service-plugin.ts:316–404) write no sys_email_template / sys_notification_template row at boot (no such insert in their src). plugin-security's start() writes nothing to an in-reach object: sys_position, sys_permission_set, sys_capability are not among the census's 44. AppPlugin seeds run after plugin-auth (every AppPlugin names it in optionalDependencies; topological order at kernel.ts:1088–1094), and the seed loader stamps sys_ / cloud_ / ai_ seeds with its sole-organization fallbackOrgId through the engine (seed-loader.ts:488–716, :1312–1318), so sys_business_unit_member is unadjudicated in PLATFORM_OBJECT_TENANCY, so seed-replayed and system-written membership rows land organization-less #14570's seed-replay row was already stamped by C1.
  • First-user sign-up: settleSelfRegistrationGrant → landSelfRegistrationGrant inserts sys_user_permission_set with the first membership's organization_id (8b-0f, auth-manager.ts:5681–5702); better-auth's sys_member / sys_invitation rows carry their organization by shape. Sign-up cannot precede start(), since kernel:listening follows every start().
  • So the report's "no producer writes before the Default Organization exists" holds on the code read, and the new dogfood pin measures it on the real showcase composition: it walks every registered platform object with a tenant column (resolveTenantFieldName on the registered schema), asks each for organization_id IS NULL rows, and floors non-vacuity on objects visited (inScope above 30; 44 expected). Caveat, non-blocking: the floor is on objects, not rows, so a producer whose insert was refused and swallowed (the webhook seeder's catch only warns) would leave 0 rows and 0 NULL rows and pass. That cannot occur at kernel:ready under single with the auth plugin mounted, because the organization exists by then; a stronger pin would also assert sys_webhook holds a row when the composition declares one. Follow-up material, not a blocker.
  • Residual, named for the record: a single composition that registers sys_organization but mounts no AuthPlugin (an IdentityObjectsPlugin-only or test composition) now REFUSES no-organization on every organization-less system insert into the 35. That is the class of about 50 test rigs this PR fixed, it is D9-correct, and the repo ships no production composition of that shape.

(d) Out-of-scope finding 2: a non-system, organization-less write into a platform object.

(e) PLATFORM_OBJECT_TENANCY kept.

  • Sound. Two readers remain: the walled branch (engine.ts:7187) and buildDriverOptions' bypassTenantAudit mute (engine.ts:6896–6945). The mute's only effect is the early return in auditMissingTenant (sql-driver.ts:15683): one WARN line, nothing a write touches. The ledger header now states both readers and warns against adding an entry to make a single write behave (platform-object-tenancy.ts:6–25); system-write-organization.ts's docblocks are rewritten to the walled-only reading (:74–78, :90–92, :163–164); index.ts:557–560 stays accurate (the ledger exists and is exported). The census tooling, its JSON and lint.yml are untouched, as claimed. Honestly documented.
  • D13 still names the ledger and isPlatformObjectOutOfTenantAuditScope for retirement; "retires with its walled reader in 18.x" is consistent with 6259698357 item 3 and 6259899486 item 4.

(f) Tests.

  • Sampled in full: the two objectql pins, the new dogfood pin, the 8c-T-α commit (5d1b91e9b: 4 files, ±5 lines, grant rows stamped with the organization their context reads under, as claimed). Sampled by hunk: plugin-security legacy-catalog-rows.testkit.ts, builtin-positions.boot, sys-audit-log-row-scope, granted-by-writer-provenance; plugin-auth sso-register-platform-admin-gate, credential-at-rest-posture, driver-fault-auth-log-carriers; dogfood single-tenant-identity-create, showcase-scope-depth, write-door-unreadable-is-not-found, wall-package-refusal.
  • The fixture rewrites model reality honestly. Organization-less residue (global grants, the retired catalogue bucket, unscoped platform-admin holders, the pre-change ledger, legacy sys_position rows) is landed with getDriver(...).create(...) and organization_id: null, beneath the engine, as rows an older release left; the readers under test still read NULL rows until 8c, so each assertion pins what it pinned. Fixtures that registered bare sys_organization / sys_user now declare managedBy: 'better-auth' as the platform does, and a single rig that needs an owner seeds a Default Organization row. Both move the rigs toward production shape.
  • Weakened assertions: the whole test patch was scanned. Five expect( lines are removed, each one of the pins the PR names as rewritten to the ruled behaviour and each replaced by the ruled assertion (tenancy-by-object-classification ×2, builtin-positions.boot's census control, single-tenant-identity-create, wall-package-refusal's grantsOf argument); two it( blocks became it.each equivalents; no .skip, .todo or xit added; no test file deleted.
  • showcase-scope-depth*: the old empty-where findOne was refused, swallowed, and a second organization minted; under 8b that would make every organization-less insert ambiguous. Asking tenancy.defaultOrgId() is the right fix and closes the trap sys_business_unit_member is unadjudicated in PLATFORM_OBJECT_TENANCY, so seed-replayed and system-written membership rows land organization-less #14570's remainder named.
  • Ablation: the mutation (!== 'single' → !== '8b-ablated') restores the exemption in every posture; the 5 objectql cases and the 3 dogfood cases that went red are exactly the cases that separate single from the wall, and the 17 other dogfood files going green under the mutation is the right control for "8b is their only cause". The dist-marker preflight and the restore evidence (empty git diff HEAD, blob hash, marker --absent) are the right shape. Credible; not reproduced here.
  • Walled dogfood: no walled dogfood file changes a walled expectation. The walled edits are in rigs whose engine ran at the default single while simulating a wall only in plugin services (the report's open question 3), and the wall-* files' HTTP doors are untouched.
  • Cosmetic: credential-at-rest-posture.test.ts:235–240 has blank lines interleaved inside a comment block. Harmless; tidy on a later touch.

② Semver level

  • .changeset/15212-8b-single-platform-insert-stamped.md: '@objectstack/objectql': minor, the launch-window level for a breaking change (scripts/check-changeset-no-major.mjs:58: "During the launch window we ship breaking changes as minor"); Check Changeset and check-changeset-no-major are green on the head. The body carries Clause-②: no (narrowing), exactly one ADR-0087 marker (adr-0087: not-required (no-migration-prescription), with its reason: no key, export, type or error code is removed, renamed or re-shaped; check-adr-0087-registration green), a BREAKING heading, Before / Now / Unchanged, and the one-line remedy (carry organization_id on the row, or tenantId on the context). That is AGENTS.md §3's form; before / now / remedy stands in for FROM → TO because there is no mapping to state.
  • no (narrowing) is the right arm: the accept-set shrinks (an organization-less system insert into the 35, previously accepted and stored NULL, is now stamped at one organization and refused at zero or several); nothing is added. Only @objectstack/objectql changes source; core's decision function is untouched, so no core bump is owed.
  • Part of #15212: right. 8b is one stage of C8; the card stays open for the 18.x stages (plan amendment 14). The Part-of PR must not also close its card check is green.
  • Part of #14570 (not Closes): right on the ruled 18.0 scope. sys_business_unit_member is unadjudicated in PLATFORM_OBJECT_TENANCY, so seed-replayed and system-written membership rows land organization-less #14570's surviving half (6067177531: a bare isSystem insert stores NULL in both postures) is fixed under single and pinned on the real showcase; under a wall the row still lands NULL in 18.0 because 6259899486 item 4 keeps the exemption there until 18.x. The claim's "Closes sys_business_unit_member is unadjudicated in PLATFORM_OBJECT_TENANCY, so seed-replayed and system-written membership rows land organization-less #14570 by construction" was written under [Decision] C8 under a wall: four framework writes that have no organization to name — self-registration grant, identity mail, activity, deployment-level secrets #24377 item 5's every-posture shape; the dev's deviation to Part of is the correct reading of the amendment. If the seat judges that 18.0's boot-path refusal of walled postures makes the walled NULL path unreachable in the release, closing is a seat ruling to record on sys_business_unit_member is unadjudicated in PLATFORM_OBJECT_TENANCY, so seed-replayed and system-written membership rows land organization-less #14570, not a PR-body change.
  • One wording defect in the remedy paragraph, non-blocking but consumer-facing (it ships in CHANGELOG): "a refusal reading no-organization means the write ran before it, or in a composition without the auth plugin's identity objects". The second cause is wrong. Without the identity objects sys_organization is not registered, the decision is no-organization-object, and the row lands with no refusal. The refusal fires in a composition that REGISTERS sys_organization but mounts nothing that creates the Default Organization (no AuthPlugin.start()). Fix, in .changeset/15212-8b-single-platform-insert-stamped.md:17: replace "in a composition without the auth plugin's identity objects" with "in a composition that registers sys_organization but mounts no auth plugin to create the Default Organization". Any later PR before the release's changeset version can carry it.

③ Boundary flags

  • Open question 1 (the ledger mute): A. The mute is diagnostics-only (sql-driver.ts:15683). Note for the record that B's stated cost does not arise: under single, auditMissingTenant exits at !this.isMultiTenantMode() (:15692, postureEnforcesWall(resolveTenancyPosture())) before it reaches the posture-independent mute, so the WARN cannot fire under single either way. A still wins on scope: one retirement with the walled reader in 18.x (D13); nothing to do here.
  • Open question 2 (sys_business_unit_member is unadjudicated in PLATFORM_OBJECT_TENANCY, so seed-replayed and system-written membership rows land organization-less #14570): A, Part of; see ②. The seat should record on sys_business_unit_member is unadjudicated in PLATFORM_OBJECT_TENANCY, so seed-replayed and system-written membership rows land organization-less #14570 that the single half landed here, pinned by showcase-single-platform-insert-default-organization.dogfood.test.ts, and that the walled half rides 18.x's walled lift.
  • Open question 3 (the test rigs): A for 18.0. Rigs that simulate a wall only in plugin services while the engine runs at the default single now land residue beneath the engine, which models legacy rows honestly. B (walled rigs set engine.setTenancyPostureProvider) is a follow-up card for 8c in 18.x, when those PIN fixtures change anyway; not a condition here.
  • Out-of-scope finding 1 (mintScimConnectionCredential): agreed; no production caller, the verifier reads the digest alone, behaviour-neutral.
  • Out-of-scope finding 2 (non-system writes): judged in ①(d) as inside D9, not an unruled widening. The seat should carry one consequence into the stage's record: under single, a second organization created at runtime now makes organization-less writes into the 35 refuse ambiguous-organization, as application objects already do. That is D9's intended loud behaviour.
  • Out-of-scope finding 3 (the sharing-rule "expands to NO recipients" log, organization: null on the declared rule itself): agreed; regime C's (plugin-sharing, runtime: sharing_rule under regime C, so every same-name row is refused and declared rules are switched through the activation ledger (ruling A on #24378) #24428 / feat(plugin-sharing,cli)!: declared sharing rules are read from the registry; the ownership ceremony deletes their seeded copies #24376), pre-existing, not a NULL membership row, untouched here.
  • Deviation, plugin-auth test-only edits (9 test files plus the testkit copy) outside the claimed surface: the claim's exclusion protected 8b-0f's merge queue; 8b-0f (fix(plugin-auth): the self-registration grant settles on the registrant's first membership (ADR-0131 D3, C8 8b-0f) #24453) had merged into the branch base before the edits; no plugin-auth source changed; the edits are the fixture class ①(f) describes. Accepted as reported; the seat records the surface widening on the claim.
  • Deviation, 49b63a479 (wip: ...) carries neither trailer of the pair AGENTS.md:460–463 requires (the two merge commits carry none either). The branch is shared (it carries another session's 5d1b91e9b), so history was not rewritten, which is the right call; every other content commit carries both trailers. Non-blocking; the squash-merge message should carry the pair.
  • Deviation, no core docblock edit: correct. resolveSystemWriteOrganization is unchanged and its JSDoc does not describe the per-object exemption; an edit would churn published .d.ts for nothing.
  • Changeset wording (②, last bullet): one sentence at .changeset/15212-8b-single-platform-insert-stamped.md:17; non-blocking; fix before changeset version.
  • Docs drift (comment 6264256422): the run could not see tenancy/platform-object-tenancy.ts and tenancy/system-write-organization.ts; any page restating the exemption needs a hand re-read. Not a blocker for the engine change; C11's fresh-install docs stage is the carrier.
  • Check-runs on the head, all concluded at render time (41 runs): 35 success, 6 skipped, 0 failed. Skipped are the opt-in or label-gated lanes: Build Docs, Console Pin Gate, the dogfood matrix shards, Dogfood Verify CLI, packed-tarball smoke, Temporal Conformance. Green: Build Core, Check Changeset, Check PR Size, Governed Surface Queue Guard, Lint & Repo Gates and every Lint family, Lint · PM dispatch-gates, Spec property liveness, Type Check (workspace, source gates, consumer gates, debt ledger), Dogfood Regression Gate, Test Core and all six shards (1/6 to 6/6, run 39231667740), Flag docs affected by code changes, Part-of PR must not also close its card, The card this PR closes must claim this branch, No other open PR may claim the same issue, No other open PR may claim the same single-writer path. Those conclusions are the gate verdicts; this record adopts none of them as its own evidence.

Rendered by an isolated at-tier reviewer subagent of the adopting seat; the seat adopts the verdict.

Implemented-by: claude/issue-15212-8b-stamp-or-refuse
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

…registers sys_organization with no auth plugin

Claude-Session: https://claude.ai/code/session_01AZMXDVkiaxSpJkGXACuVwr
Co-authored-by: Claude <noreply@anthropic.com>
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