Repository navigation
feat(plugin-auth,objectql,metadata-protocol,runtime)!: under single the Default Organization exists before the seeds and the listener; an unowned seed row or system write is derived there or refused (ADR-0131 C1) - #22186
Conversation
…tion (ADR-0131 D9) Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
… invariant, owner promotion, seed stamping (ADR-0131 D3/D9) Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…boot tests to ADR-0131 D3/D9 Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…fault-org-load-bearing
…fault-org-load-bearing
…nternal, stamp only seed rows that carry an organization, real-kernel pin Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…nciler binds the first admin before the owner bind Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…fault-org-load-bearing
…irst boot, the measured Clause-② line Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…the tenant-audit census counts the two new system writes Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…lls; the pinned-double ledger records the auth-plugin update double Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
… the seed-stamp double refuses a WHERE combinator Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…fault-org-load-bearing
…fault-org-load-bearing # Conflicts: # content/docs/permissions/tenant-audit-census.mdx # docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
…e, with the two system writes C1 adds (234 to 236) Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 5 package(s): 32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 46 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4e660734be2af04fb2c7d25f20336130a9f53c76 && git checkout 4e660734be2af04fb2c7d25f20336130a9f53c76
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6729e107e806b148d809e42b62e7fc0233d3d324 ede1fbd3459d826e884d5c5ba3e3e0476edd0961 && git checkout -B drift-repro 6729e107e806b148d809e42b62e7fc0233d3d324 && git merge --no-ff ede1fbd3459d826e884d5c5ba3e3e0476edd0961
node scripts/docs-audit/affected-docs.mjs --json 6729e107e806b148d809e42b62e7fc0233d3d324
|
…keeps only the vacuity guard Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…boot, each on its own cause Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…equence until C8; two harness comments corrected Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…fault-org-load-bearing
…fault-org-load-bearing # Conflicts: # content/docs/permissions/tenant-audit-census.mdx # docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
…in merge, with the two system writes C1 adds (234 to 236) Generated tables written by `node scripts/tenant-audit-census.mjs --write` on the committed merge; the hand-written prose figures the gate holds to the census moved with them (erased receivers 48 to 50, decidable 154 to 156, elevated 123 to 125, population 234 to 236). Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…uction single shape — the Default Organization exists Both rigs register `sys_organization` and then system-insert a tenant-scoped row on an install that holds no organization: the shape ADR-0131 C1 refuses (D9, `no-organization`) and no production `single` boot reaches, because the auth plugin's boot invariant creates the Default Organization first (D3). - plugin-approvals `status-mirror-cascade.integration.test.ts`: provisions `sys_organization` and seeds the Default Organization before the first insert; `sys_organization` leaves the expected-absent probe list, by that channel's own contract (a table that started resolving is provisioned now). - plugin-security `claim-seed-ownership-warm-boot.test.ts`: every boot finds or creates the Default Organization, as the invariant does on every boot. The subjects (the approval cascade's identity, the warm-boot seed claim) are unchanged; the fixtures changed. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…lt Organization, the member's own The rig composes the identity objects (so `sys_organization` is registered) and no auth plugin, then runs a user-less system flow whose `create_record` is a system insert into a tenant-scoped object: refused under ADR-0131 C1 (D9, `no-organization`), a shape no production `single` boot reaches. The rig now seeds the Default Organization as `org_1`, the member's organization, and the NULL-born case pins the row's derived organization so the 403 is decided by the stamp columns alone. The lean rig above (no organization object) is unchanged. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Isolated, adversarial read of PR #22186 at the head above (card #15195, ADR-0131 C1). Inputs: the card's body and all 13 comments (triage's scoping 6040634630 Q1 → B, triage's landing ruling 6053354661 Q1 → A / Q2 → A, the take-over claim 6049583616 and its revision, the three os-dev-reports 6052898415 / 6059821487 / 6064009112), ADR-0131 and ADR-0093 D7 as they stand on ① Derived judgmentsThe narrowing, against triage's scope (Q1 → B). Each piece judged against ADR-0131's text:
The public surface, on the built entry declarations. The dev's list is right, and two of its objectql items are NARROWINGS of a published TypeScript surface, not widenings — named here so the record says so:
The re-pinned tests. 15
No subject was weakened, skipped or deleted to reach green.
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…ore the settings engine binds (objectstack-ai#22257) (objectstack-ai#22312) Fixes objectstack-ai#22257 Clause-②: no ## What was wrong On `examples/app-showcase`, `os dev --seed-admin --fresh` logged one `[SettingsService] Pre-bind READ of namespace 'auth'` on every boot. Re-measured on `origin/main` `79c35d45` (after PR objectstack-ai#22295): 1 line, in the boot-diagnostics block. **The reader.** A temporary stack capture in the built `reportPreBindRead` (applied to `service-settings/dist/index.js` and reverted; the sha256 matched before and after, and the marker count after the revert was 0) named it: `SettingsService.getNamespace` from `AuthPlugin.bindAuthSettings`, then `applySettings` (`auth-plugin.ts:1535` at `79c35d45`), from `AuthPlugin.ensureAuthSettingsBound`, from `runBackfill` (`:1235` / `:1244`), from the kernel's `trigger`. The handler is the `app:seeded` hook registered in `AuthPlugin.start()` (`:1298`), which calls `runBackfill('app:seeded')`. It is the ADR-0093 D6 one-time membership backfill. **The phase.** A debug-level boot puts the read in Phase 2 (start). It comes after `[Seeder] Seed loading complete` (132 rows, `plugin.app.com.example.showcase`) and before `Triggering kernel:ready hook`. `AppPlugin.start()` emits `app:seeded` when its inline seed lands. On `os dev`, the auth plugin started first, so its handler ran during Phase 2. `SettingsServicePlugin` binds the engine in its `kernel:ready` hook (`settings-service-plugin.ts:221`), which is later. The `optionalDependencies: ['com.objectstack.service.settings']` edge on `AuthPlugin` orders only `kernel:ready` HOOKS. An event fired during Phase 2 is outside it. **The consequence.** `ensureAuthSettingsBound` is memoized, so the auth binding was computed from the manifest defaults. In the debug log, `Auth: bound to settings namespace=auth` appears at 14:18:11.661, before `kernel:ready` at 11.672. The persisted rows were re-applied later only through a fire-and-forget subscription callback. The one-time pass's first run read the default policy `auto`. **Why `check:settings-bind-window` missed it.** The gate walks `init()` / `start()` bodies and `kernel:ready` handlers only (`READY_HOOK`). It reached this same read through the `kernel:ready` registration of `runBackfill`, and classified it `declared`. The `app:seeded` registration fires during Phase 2, from another plugin's `start()`, and is not in the gate's population. This is reported as a blind spot below; this PR adds no gate. ## The fix (`packages/plugins/plugin-auth/src/auth-plugin.ts`) The one-time pass is now armed by its own `kernel:ready` hook. Any trigger before that does nothing: `app:seeded` during Phase 2, or `default-org-created` from the bootstrap middleware. The `kernel:ready` pass still runs afterwards, after the settings bind, and scans every row such a trigger was about. Triggers after `kernel:ready` behave exactly as before. That includes an over-budget seed that settles in the background (the objectstack-ai#2996 path). - No change to which settings are read, only to when. - No change to the reporter's level or text. - No `packages/spec` change. - The comment above `ensureAuthSettingsBound` in `runBackfill` was stale: it said "registered in `init()`". It is corrected. ## Boot, before and after | | `Pre-bind READ` lines | `Auth: bound to settings namespace=auth` | |---|---|---| | `79c35d45` (base) | 1 (`auth`) | Phase 2, before the `kernel:ready` trigger | | `9b7ac6cf` (fix; plugin-auth dist rebuilt, `backfillArmed` grep 3 in `dist/index.mjs`) | 0 | after the `kernel:ready` trigger (16.538 vs 16.381) | In both boots the D6 pass still records `adr-0093-membership-backfill` once. The other boot-diagnostic lines are unchanged: `sys_migration` UNIQUE and `[sharing-rule]`. See the acceptance notes. ## Pins `packages/plugins/plugin-auth/src/auth-settings-seeded-boot.pin.test.ts` composes: - a real `ObjectKernel` - ObjectQL on in-memory SQLite - the REAL `SettingsServicePlugin` - the real `AuthPlugin`, used before the settings plugin (the `os serve` order) - an app plugin in `AppPlugin`'s place: it writes the persisted `auth.membership_policy = invite-only` row, then emits `app:seeded` from its `start()`. The dogfood harness cannot compose this. `bootStack` registers `AppPlugin` before `AuthPlugin`, so `app:seeded` fires before auth registers its handler. The main case asserts four things: 1. The window is real: at `app:seeded`, the settings engine is unbound. 2. No `Pre-bind READ` is reported. 3. The auth binding's first `getNamespace('auth')` answers `{ value: 'invite-only', source: 'global' }`. 4. Every run of the one-time pass gets `policy: 'invite-only'`. A control case forces a pre-bind read of `auth` in the same composition and expects exactly one report. `@objectstack/service-settings` is added as a devDependency of plugin-auth. It is aliased to `src/` in `vitest.config.ts` (anchored, like the three existing entries), so `KNOWN_UNALIASED_TEST_IMPORTS` is unchanged. The lockfile gains only that importer entry. **Control.** `settings-prebind-read-warning.test.ts` passes 9/9 unchanged. It still fires on a forced pre-bind read. **Ablation**, via `scripts/ablation-replace.mjs` from committed `d907051b`, with an EXIT/INT/TERM restore and the restore proven by blob == HEAD and an empty `git diff HEAD`. `AuthPlugin` is imported relatively, so `src/` is what runs and no dist rebuild was involved. - Leg 1: delete `if (!backfillArmed) return backfillChain;` (anchor 1 to 0, blob `d559d607` to `83e4649d`). The main case goes RED at assertion 2: one `Pre-bind READ of namespace 'auth'`. The control stays green. Restored to `d559d607`. - Leg 2: the same deletion held, plus assertion 2 removed from the test. The main case goes RED at assertion 3: the first `auth` read answered `{ value: 'auto', source: 'default' }`. Both files restored (blob == HEAD). ## Verification (the gate union was run on `b67e95f1`) - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: 129 files, 2639 passed, 10 skipped, exit 0. This ran on `d907051b`. The merge after it brought only `docs/adr/0096`. - `pnpm --filter @objectstack/plugin-auth run typecheck`: exit 0. `check:test-typecheck` is OK, and the new file is in the `tsconfig.test.json` program (`--listFiles`: 1). - `node scripts/pm/dispatch-gates.mjs --commands` (no paths) at `b67e95f1` derived 79 commands, and all 79 exited 0. `--ran`: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm check:settings-bind-window`: `4 declared / 0 self / 1 structurally upstream / 0 ledgered (73 plugin unit(s) scanned)`. - Selected verdict lines: - `check-test-source-alias OK — 73 packages with tests scanned; 60 registered` - `check:workspace-manifest-cycles OK: 80 workspace package(s), 515 workspace: edge(s)` - `check-nul-bytes: OK` - `check:undeclared-dep-imports` passed - `check-adr-0087-registration: 1 non-breaking changeset(s) seen` - eslint, narrowed to the 3 changed TS files: 0 errors and 0 warnings in `--format json`. Each file has a non-empty rule set under `--print-config`. `eslint.config.mjs` enables no type-aware linting, so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is left to CI. - NOT MEASURED: CI's Test Core, Dogfood, Build Core and the type-check lanes. These are CI-owned. ## Acceptance notes - **Gate blind spot (not fixed; no gate added).** `check:settings-bind-window` cannot see two things: - a settings read reached through a hook other than `kernel:ready` that fires during Phase 2 (`app:seeded`, emitted from `AppPlugin.start()`); - a read reached through a data-middleware or callback closure fired by a Phase-2 write. Its header says "Everything that runs before that bind hook is the window." Its population is narrower than that window. - **Landing order with PR objectstack-ai#22186.** That PR makes `AppPlugin` declare `com.objectstack.auth` as an optional dependency, and creates the Default Organization in `AuthPlugin.start()`. Without this fix, the Phase-2 `app:seeded` pass would then have a target and would DECIDE the one-time backfill under the manifest-default policy. This fix arms the pass at `kernel:ready`, so it holds under either landing order. The two diffs touch different regions of `auth-plugin.ts`: theirs is around `:725` and `:1168`, this one is `:1232` to `:1316`. - **Unchanged boot line.** `WARN Insert operation failed {"object":"sys_migration", … UNIQUE constraint failed: sys_migration.id}` appears on the fresh showcase boot both before and after this change. It comes right after `adr-0093-default-org-owner-bind` is recorded. Root cause is NOT MEASURED; it is reported to the seat. - **Merge state.** `origin/main` was merged at `799eb000`. `main` has since moved 4 commits. None touches plugin-auth or service-settings; plugin-security's strict mode is the nearest, and this composition mounts no `SecurityPlugin`. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ogs no refused sys_migration insert (objectstack-ai#22336) Fixes objectstack-ai#22099 Clause-②: no The one-time owner-bind gate (`createEnsureDefaultOrganizationOnce`, `packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts`) now marks its decision in flight synchronously, before its first `await`. A call that finds the decision in flight runs `ensure(ql, { bindOwner: false })` and records nothing. The deciding call records its own outcome once. A call that did not act (`no_admin`, a refused write) clears the mark on the way out, so the next trigger still decides. This is the direction triage ruled (`6042758404`, option A). There is no promise chain, no ledger upsert or insert-if-absent, and no catch-all. Triage folded objectstack-ai#22102 into this card as a duplicate (same row, same mechanism); objectstack-ai#22102 remains open, and its disposition is the seat's. ## Re-measured on the landed shape (`origin/main` `28bff18d`, after PR objectstack-ai#22186) The gate still re-enters itself. The Default Organization boot invariant (ADR-0131 D3) changes which write re-enters it: the outer call now PROMOTES the reconciler-written `member` row instead of inserting `sys_member`. The chain on a first boot, outermost first (probe stacks through `bootStack`): - `kernel:ready`, `security-plugin.ts:4701` `runBootstrap`, `bootstrap-platform-admin.ts:1170` `promote`, `:407` insert `sys_user_permission_set`; - plugin-auth middleware `auth-plugin.ts:1255`, `runEnsure` `:1224`: the OUTER call; - `ensure-default-organization.ts:498`, `promoteReconciledMemberToOwner` `:379`, `ql.update('sys_member', { role: 'owner' })`; - security-plugin middleware `security-plugin.ts:5145`, `reconcileOrgAdminGrant` `auto-org-admin-grant.ts:777`, `:238` insert `sys_user_permission_set`; - plugin-auth middleware `auth-plugin.ts:1255` again: the INNER call. It records `{ outcome: 'admin-already-member' }` first. The outer call's `{ outcome: 'promoted', organizationId }` is then refused by the primary key. | boot (CLI `os serve`, `examples/app-crm`, `NODE_ENV=development`) | `sys_migration` failed-insert lines, before | after | |---|---|---| | `:memory:` | 1 | 0 | | file SQLite, first boot | 1 | 0 | | same file, second boot | 0 | 0 | | `bootStack` (dogfood harness, empty app) | `adr-0093-default-org-owner-bind` insert attempts, before | after | |---|---|---| | `:memory:` | 2 (second refused) | 1 | | file, first boot | 2 (second refused) | 1 | | same file, second boot | 0 | 0 | Persisted owner-bind `details` (file DB, read back): - before: `{"outcome":"admin-already-member"}`, while `sys_member` held the admin as `owner` of the one default organization; - after: `{"outcome":"promoted","organizationId":"org_muzty5e5bxpe2dtt"}`, which is the organization of the admin's `owner` row. Walled first boot (`bootStack`, `isolated`, `OrganizationsPlugin` mounted), measured on `main`: ONE owner-bind insert, `{"outcome":"bound","organizationId":…}` matching the operator's `sys_member` row, and no warning. Under a wall the grant-insert arm of `isDefaultOrganizationBootstrapTrigger` is retired, so this chain does not re-enter the walled wiring today. The walled wiring calls the same gate and gets the same rule. ## Pins - `packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts`: the gate over a fake engine whose `sys_migration` insert refuses a duplicate id and whose `sys_member` writes re-enter the gate. - a fresh bind records `bound` once, with the org `sys_member` points at; - the promotion (the landed boot shape) records `promoted` once; - a concurrent trigger binds nobody: one owner row, one record; - a call that did not act clears the mark; - CONTROL: a real ledger insert failure still reaches `recordLedgerDecision`'s `error` branch. - `packages/plugins/organizations/src/walled-default-org-owner-bind-in-flight.pin.test.ts` sits beside `walled-default-org-self-registrant.pin.test.ts`. A `sys_user` `email_verified` update reaches `OrganizationsPlugin`'s own bootstrap middleware while the bind is in flight. The ledger is written once, `bound`, with the org `sys_member` points at. - `packages/qa/dogfood/test/sys-migration-boot-ledger-once.dogfood.test.ts` is the family's enumeration pin. It boots a fresh database twice over one file through `bootStack`. The ids come from the table's own rows, and each one must have been inserted exactly once. No line at `WARN` or above may name `sys_migration`. The owner-bind row must read the deciding call's outcome with its owner's organization. The control requires the capture to have parsed an `INFO` line naming the ledger. - **Deviation from the ruled pin text:** triage's pin says the persisted `details` read `bound`. On the landed full boot the accurate outcome is `promoted` (ADR-0131 D3 binds the admin as `member` before the gate runs). So the dogfood pin asserts `promoted`, and the `bound` arm is pinned at the unit and walled layers, where the gate inserts the owner row. ## Ablation (at `d286091a08`, fix committed first; delete the synchronous mark) The mark (`deciding = true;`) was deleted with `scripts/ablation-replace.mjs`: anchor 1 → 0, blob `27c001b5` → `635eb0e8`. plugin-auth was rebuilt (exit 0), and `ablation-dist-preflight --absent 'deciding = true'` passed. - plugin-auth pin: 3 failed, 2 passed. The re-entry, promotion and concurrent cases are red; the clears-the-mark case and the CONTROL are green, as predicted. - walled pin: 1 failed. `expected [ …(2) ] to have a length of 1 but got 2`. - dogfood pin: 3 failed, 1 passed. `adr-0093-default-org-owner-bind: 2` against 1; `boot 1: expected [ Array(1) ] to deeply equal []` (the warning); `{ outcome: 'admin-already-member' }` (the lost update). The control was green. The restore was proved: blob `27c001b5` matches HEAD and `git diff HEAD` is empty. plugin-auth was rebuilt and the preflight found `deciding = true` in dist again. `git status --porcelain` was empty. A first ablation leg deleted only the `|| deciding` read. Its JS reached dist, but the build exited 1 at the DTS step (TS6133: `deciding` was never read). That leg is VOIDED as a reading, and the leg above replaces it. Its colours were the same. ## Verification The package runs below are at `33520be606`. The final commit `82ae109f5e` changed only the plugin-auth pin's `findOne` double and the generated `scripts/engine-double-contract.pinned.json`, and the runs it can move were repeated there. The gate union ran at `82ae109f5e`. - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: 130 files, 2662 passed, 10 skipped. At `82ae109f5e` the new file re-ran 5/5 and `check:test-typecheck` re-ran OK. - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0. `check:test-typecheck` held 10 files / 94 errors, unchanged. - `pnpm --filter @objectstack/organizations exec vitest run --maxWorkers=2`: 12 files, 152 passed. Its `typecheck` exited 0. - `pnpm --filter @objectstack/dogfood typecheck`: exit 0. The enumeration pin: 4/4. - The full dogfood suite is NOT run locally; it is declared to CI's `Dogfood Regression Gate`. Only the new file ran here. No importer of `plugin-auth` owes a test: the diff changes a function body and a module comment, and no exported declaration. - ① `turbo run build --filter='@objectstack/plugin-auth^...' --filter='@objectstack/organizations^...' --filter='@objectstack/dogfood^...' --concurrency=1`: 63/63. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 76 commands from this branch's change set at `82ae109f5e`. All 76 ran there and exited 0. `--ran` reconciled them: `76 derived famil(ies) accounted for — 76 run, 0 NOT-MEASURED`. Sample verdict lines: - `check-engine-double-contract: OK — 986 pinned, 129 in the DEBT ledger, 3 exempt.` - `check-nul-bytes: OK (scanned 10308 text file(s) …; no raw ASCII control bytes).` - `check-test-source-alias OK — 73 packages with tests scanned; 60 registered as still resolving a workspace dep through dist/ …` - `OK: 30 package(s) read outside themselves, all declared …` (`check:cross-package-test-inputs`). - The first pass at `33520be606` had two non-zero lines. `check:engine-double-contract` was red: the new findOne double was not routed through `assertEngineFindOnePredicate`, and the ledger lacked the new rows. Repaired in `82ae109f5e`. `check:dual-build-cjs-loads` printed `PREREQUISITE NOT MET` (8 packages had no dist). It was NOT MEASURED then, and it exited 0 after those dists were restored from the turbo cache. - eslint, narrowed (CI owns `pnpm lint`): 4 files, 0 errors and 0 warnings by `--format json`. Each file is matched by `eslint.config.mjs` (`--print-config`), and none is ignored. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. ## Acceptance notes - `scripts/engine-double-contract.pinned.json` gains three generated rows (`--write`, grow-only coverage ledger) for the two new pinned doubles. This file is outside the claim's declared surface, and the gate requires it for any new pinned double. - Read-only inference, not reproduced, and no card filed (carrier: none). On a kernel with NO ledger, an in-flight caller runs `ensure` with `bindOwner: false` and may still CREATE the default organization while the deciding call waits. The decider then sees an existing organization under `bindOnlyOnCreate` and binds nobody. `ensureDefaultOrganization`'s org creation was already not concurrency-safe (two concurrent calls can both insert one). Every served kernel composes the ledger (`PlatformObjectsPlugin`), and `single` creates the organization at boot. - Not measured: a production first sign-up (no dev admin). The gate's rule does not depend on which trigger re-enters it. - Rows already written as `admin-already-member` are not rewritten. Nothing reads `details`: `readLedgerDecision` answers existence only. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #15195
Clause-②: yes (narrowing)
ADR-0131 C1: the Default Organization is load-bearing under
single. Scope as triage ruled it: 6040634630 (Q1 → B) for the implementation, 6053354661 (Q1 → A, Q2 → A) for landing it. This is one atomic, cross-lane PR. It carries the@objectstack/verifybootStackre-pin and the dogfood re-pins with the implementation.packages/qa/dogfoodandpackages/verify(domain:cli) join it, declared on #6024.What this does
singleposture,AuthPlugin.start()finds or creates the Default Organization (slug: 'default'), with or without a platform admin. A failed read or insert throws and fails the boot.AppPlugindeclarescom.objectstack.authas an order-if-present dependency, so the kernel starts the auth plugin first wherever a host registered it.kernel:listening.organization_idcolumn,sys_/cloud_/ai_seeds included.memberbefore the owner bind learns who they are.ownerin place.promoted.resolveSystemInsertOrganizationderives at exactly one organization. It refuses at zero (new:reason: 'no-organization'), at several, and under a wall.ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED(status 500).packages/specedit.bootStackboots the productionsingleshape (D3 / D11). The harness no longer pins the owner bind off (autoDefaultOrganization: !!opts.orgContextis gone). Every boot has the Default Organization, every sign-up is its member, and the harness admin is its owner, asobjectstack dev/serveboot it.orgContextis now only the vacuity guard: it asserts that the admin's session carries an organization, and refuses the boot otherwise. It still refuses to compose withmultiTenant: 'posture-only'.isPlatformObjectOutOfTenantAuditScopeuntil C8.probeInstallOrganizationsanswering[]when no organization object is registered.applyTenantScopeis touched.Boot order: fresh
single,examples/app-showcasethroughbootStack(measured)Base
51290bca2c, implementation headc49f46bab1. Seeds: 132 rows over 19 objects. The last two rows were measured with the harness at its old pin. Since this PR,bootStackalways boots the owner-bind-on row.AuthPlugin.start()AppPlugin.start()inline seedorganization_idNULL (sys_business_unit5 of 5 NULL)sys_business_unitincludedkernel:readykernel:listeningkernel:ready; even then the organization was the 49th insert, after every seed, and 130 of 132 seed rows stayed NULLbootStacknow, always)ownerdirectlymember; the bootstrap logs "promoted the platform admin to owner";isPlatformAdmin: true, positionsplatform_admin,org_ownermemberof the Default Organization, the session's active organizationThe derivation rule:
resolveSystemInsertOrganization, objects in scopesinglesingleno-organization(measured, pins)singleambiguous-organizationsingleisolated/groupwalled-postureposture-onlyisolated showcase boot: codeERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED, status 500; the same insert carryingtenantIdlands stamped)Acceptance pins (implementation head
4fc2472fd0, baseec8f37c890)runtime/src/default-organization-boot-invariant.pin.test.tsboots a real kernel twice:objectstack dev, where the dev admin is created after the organization exists.AppPluginis registered beforeAuthPluginon purpose.isSysteminsert with no organization lands stampedtenancy: { enabled: false }takes noneisolated/grouprefuse, with the code and status; the insert carryingtenantIdlandsobjectqlsystem-write-organization.test.tsand the measured isolated boot aboveisPlatformAdminread back unchangedsys_seeds includedobjectqlandmetadata-protocol[ADR-0131 Q2, held as-is]casesReverse verification of the implementation (from committed
4fc2472fd0):ec8f37c890in the tree only.scripts/ablation-dist-preflight.mjsconfirmed each head marker absent fromdist/.git diff HEADempty and per-path blob hashes equal to HEAD.Ablations (
scripts/ablation-replace.mjs, each restored and rebuilt):member, notowner).com.objectstack.authremoved fromAppPlugin.optionalDependencies: every seed row is refused, and (a)'s ordering case and (d) turn red.M5: the existing tests C1 moved, judged one by one
Each flip was judged against the ruling and none was restored. There are two judgements:
seed-loader-sole-organization-read-failure.test.tsseed-loader-org-fallback.test.tssys_seeds take the organization; ambiguity refusesengine-organization-probe-outage.test.ts(cause 2)system-write-tenancy-autonumber-split.integration.test.tsauth-plugin.test.ts, "autoDefaultOrganization: falseopts out"seed-loader-engine-schema-fallback.test.tsseed-loader-existing-records-read-failure.test.tsprotocol-publish-package-drafts.test.tspackages-seed-apply-disclosure.test.ts,packages-seed-apply-read-decorations.test.ts,http-dispatcher.test.tsseed-tenancy-autonumber-split.integration.test.tsauth-plugin.test.ts, the twoapp:seededcasesstatus-mirror-cascade.integration.test.tssys_organizationbut left it unprovisioned and empty, then system-insertedopportunity, so CI refused it (no-organization). It now provisionssys_organizationand seeds the Default Organization before the first insert.sys_organizationleaves the expected-absent probe list, by that channel's own contract (a table that started resolving is provisioned now). The subject, an approval decision cascading as the deciding user, is unchanged. The delegation channel still fires (afterAllgreen).claim-seed-ownership-warm-boot.test.tscrm_caseseed inserts in four cases. Every boot of the rig now finds or creates the Default Organization, as the invariant does on every boot. The subject, the warm-boot seed-ownership claim and its target, is unchanged: all 5 cases pass with the same expectations.runas-system-stamping.integration.test.ts, the SecurityPlugin flip blockcreate_recordwas refused (found by the sweep below; CI never reached this suite). The rig now seeds the Default Organization asorg_1, the member's organization. The NULL-born case also pins the row's derived organization (org_1), so the403is decided by the stamp columns alone, which is the subject. The lean block above it registers no organization object and is unchanged.None of these weakens the
no-organizationrefusal, adds tolerance in the engine, or skips a case.bootStackand the dogfood: the 24 re-pinned filesAt this PR's implementation alone, 24 dogfood files failed; at base they pass. Each was re-pinned on its own cause, to the production
singleshape. Pins that C1 flips flip to the new answer and none is deleted. The shared helper isleaveOrganization(new,test/armed.ts): it deletes the user'ssys_memberrows in system context, signs in again, and throws if a membership survives. A user removed from their organization is an ordinary production state, not a test mode.analytics-adhoc-query-isolationmemoryleg:driver-memoryrefuses a tenant-scoped read (503), and every session now carries the Default Organizationmemoryleg became anobjectql-strategyleg onsqlite-wasm, with the analytics plugin'squeryCapabilitieswithholding native SQL.Restart-when: #15212 closedanalytics-contains-membershipanalytics-inline-dataset-admissionanalytics-inline-dataset-isolationarmedorgless/orgboundrenamedoutside/inside; the outside principal leaves the organization; the write-floor disarm text names "Keep the principal a member of the organization"delegated-admin-inviteslug: 'default'organization (DuplicateRecordError)delegation-of-dutysys_position/sys_user_positionrows carry the Default Organization; the session double carriesactiveOrganizationIdinvitation-ledger-row-scopeacme-8095) while the reconciler binds every sign-up to the Default Organizationmembership-actor-attributiondefaultorganizationmembership-ended-session-revokedefaultorganizationmembership-reconcilerdefaultorganizationmembership-role-vocabularydefaultorganizationorg-admin-affordance-reachreach-orgwhile sign-ups bind to the Default Organizationorganization-delete-federated-fixtureorg-21910) that the admin owns and no row belongs toparent-derived-write-refusal-not-visibleboot(inside): the outside principal leaves the organization; the inside boot keepsorgContext: truepermission-set-lock-row-provenancememberobjectstack devboots itpermission-set-write-through-package-bindingpredicate-write-unreadable-not-matchedparent-derived-write-refusal-not-visiblesharing-rule-org-less-calleradmin_full_accessglobally who leaves it. The control persona's Default membership is removed before its tenant-A one. Preconditions judge live sessions only, because leaving revokes the sign-up session (#15784)showcase-permission-projectionmemberpermission-set-lock-row-provenancesingle-tenant-identity-createsys_business_unitwith no organization, is the defect C1 fixesorganization_idequals the Default Organization's idVALIDATION_FAILED) is still the pinsys-file-metadata-write-refusalmemberpermission-set-lock-row-provenancetwo-doors-permissionmemberpermission-set-lock-row-provenancewrite-door-unreadable-is-not-foundparent-derived-write-refusal-not-visibleFiles beyond the declared set (
packages/verify/src/harness.tsand the 24 files):packages/qa/dogfood/test/armed.ts, which holds theleaveOrganizationhelper.packages/verify/src/harness.org-context.test.ts. The default-boot case now pins the production shape, including the admin'sownermembership.showcase-external-autoconnect.dogfood.test.tsandshowcase-scope-depth.dogfood.test.ts: comments only, correcting the description of the removed org-less boot..changeset/15195-verify-bootstack-production-single.md.domain:services) test fixtures, round 3, tabled under M5:packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts,packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.tsandpackages/services/service-automation/src/runas-system-stamping.integration.test.ts.content/docs/permissions/tenant-audit-census.mdx,docs/audits/2026-08-tenant-audit-write-call-sites.counts.md). After eachmainmerge it was regenerated with the gate's own write mode, outside the MERGE state, and the prose figures the gate holds were moved with it: 234 → 236.Reverse verification of the harness change (committed
05dedeea79, and again on9aee4d05f1with identical results; round 3 changes no file it reads):scripts/ablation-replace.mjs(WRAP mode) restored the old pin inharness.ts, with a string-literal marker (REVERSE-15195-OLD-PIN) the bundler keeps.@objectstack/verifywas rebuilt, andablation-dist-preflightconfirmed the marker present indist/.harness.org-context.test.ts: 1 of 5 failed (expected [ 'member' ] to deeply equal [ 'owner' ]).git diff HEADempty.@objectstack/verifywas rebuilt, and the preflight--absentpassed.The same leg on
c2f7e36d6eleft the harness unit test green, because its two assertions hold without the owner bind.05dedeea79adds theownerassertion so the unit test reads the line itself. The first attempt was void and is not counted: the tool refused it because the replacement contained the anchor, and nothing ran.The in-memory driver until C8 (triage Q2 → A)
@objectstack/driver-memoryrefuses tenant-scoped reads. Undersingle, every session now carries the Default Organization, so on that driver signed-in reads answer503until C8 (#15212, ADR-0131 D8) givessingleno read predicate. Theplugin-authchangeset states this.This is not new in production terms. At base, a showcase boot with the owner bind on (the shape
objectstack devboots) already answered the platform admin'sGET /data/showcase_categorywith503 SERVICE_UNAVAILABLEon the memory driver. Only the harness's org-less pin kept the four analyticsmemoryvariants green. Those variants now run on the SQL in-memory driver, withRestart-when: #15212 closedin each file.Clause-②: the built entry declarations, base
ec8f37c890against head@objectstack/objectql:SystemWriteOrganizationDecision's'no-organization-yet'becomes'no-organization-object'.SystemWriteRefusalReasongains'no-organization'.resolveSystemWriteOrganizationtakes a requiredorganizationObjectRegistered.@objectstack/plugin-auth:EnsureDefaultOrganizationOnceOptionsgains an optionalorganizationCreatedByThisProcess.EnsureDefaultOrganizationResultgains an optionalownerPromotedand the'owner_promotion_failed'reason.@objectstack/verify: no declaration change.bootStacknow boots an org-bound admin for every caller, so a fixture that relied on an org-less one breaks.@objectstack/metadata-protocol,@objectstack/runtime: no public declaration changes.The accept sets narrow, so the answer is
yes (narrowing). The objectql, plugin-auth, metadata-protocol and verify changesets areminor, with the BREAKING banner and an ADR-0087 disposition. The runtime changeset ispatch. Driven offline with this body as thepull_requestpayload (--event),check-changeset-no-majorreadsClause-②: yes (narrowing)and passes the level axis.check-adr-0087-registrationalso passes.Verification (head
ede1fbd345, merge base9f0de32a03)Every package that depends on
@objectstack/objectqlwas run in full. That is 45 packages (pnpm --filter '...@objectstack/objectql'); 44 have atestscript, andmetadata-protocolwas added. A package that does not registersys_organizationcannot reach theno-organizationrefusal, but the sweep measured every package rather than relying on that argument. In each log, everySystemWriteOrganizationRequiredError/no-organizationmatch was read; none remain after the fixes.ede1fbd345(plugin-approvals and plugin-security also ran atb7d0b3469e, which already carried their fixes):plugin-approvals: 62 files, 905 passed.plugin-security: 179 files, 3,775 passed, 45 skipped.service-automation: 175 files, 2,120 passed.runtime, re-run atede1fbd345: 339 files, 5,495 passed, 19 skipped.cli: unit tier 264 files, 3,915 passed. Integration tier in four slices: 93 files, 900 passed, 2 skipped.b7d0b3469e/ede1fbd345:9aee4d05f1. Round 3 changes no file these read:OS_TEST_SHARD=k/8): 222 files (1 skipped), 1,753 tests passed, 9 skipped.objectql: 381 files, 7,527 passed.metadata-protocol: 222 files (3 skipped), 28,283 passed.plugin-auth: 128 files, 2,655 passed.verify: 18 files, 133 passed.ede1fbd345(check:test-typecheckOK: the plugin-approvals debt ledger is held, the other two ledgers are empty). Earlier: objectql, runtime, metadata-protocol, plugin-auth, verify and dogfood.dispatch-gates --commandsderived 112 for this tree (two i18n families joined), all 112 ran with exit 0, and--ranreconciled 112 of 112 with recorded exit codes. The roster gates whose list lives in a touched directory all exit 0.check-single-claim-pathspassed with this PR's context.eslint --no-inline-configover the 51 script and TypeScript files this diff adds or modifies: 0 errors and 0 warnings, 51 files in the JSON report. The config enables no type-aware linting, so this diff cannot move an untouched file's verdict. The fullpnpm lintrun is CI's.showcase-security.tsis touched.mainsince the merge base: 27 commits, not merged here.git merge-treeagainst it is clean. 16 test files they add or change name the organization object (for exampleplugin-security/src/grant-holder-membership-refusal.test.ts,service-settings/src/settings-organization-isolation.pin.test.tsanddogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts). They are NOT MEASURED against C1 here; CI's merge-ref run measures them.Acceptance notes
carrier:the feat(objectql,plugin-auth): the Default Organization is load-bearing undersingle; an unstamped write is derived there and refused everywhere else (ADR-0131 D3/D9/D11) #15195 claimant.admin-already-membersecond insert (DUPLICATE_RECORDon two concurrent triggers, present at base too) is [finding] every artifact boot on a fresh:memory:database logsInsert operation failed … UNIQUE constraint failed: sys_migration.idwith a full knex stack in Boot diagnostics #22099.packages/plugins/plugin-sharing/src/sharing-service.ts,sharing-rule-service.tsandsharing-service.test.tsstill describe the harness'sautoDefaultOrganization: false. They are not edited here (outside the declared set).carrier:none named, so this is noted here only.Generated by Claude Code