Skip to content

Commit 34dba5a

Browse files
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)
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/verify` `bootStack` re-pin and the dogfood re-pins with the implementation. `packages/qa/dogfood` and `packages/verify` (`domain:cli`) join it, declared on #6024. ## What this does - **The boot invariant (D3).** Under the `single` posture, `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. - `AppPlugin` declares `com.objectstack.auth` as an order-if-present dependency, so the kernel starts the auth plugin first wherever a host registered it. - The organization therefore exists before the inline seed and before `kernel:listening`. - **The seed-exemption withdrawal (D3/D9).** The seed loader stamps the install's organization on every row of an object that carries an `organization_id` column, `sys_` / `cloud_` / `ai_` seeds included. - Suppose no organization is pinned and none can be derived (zero organizations, or several), on an install that registers the organization object. Such a row is refused, counted and named. - An object with no organization column is never stamped. - **The owner pin (D3 / ADR-0093 D7).** The reconciler binds the first admin as `member` before the owner bind learns who they are. - While the once-only bind is undecided and the Default Organization has no owner, the bootstrap promotes that row to `owner` in place. - It records the decision as `promoted`. - **The derivation rule for the objects already in scope (D9).** `resolveSystemInsertOrganization` derives at exactly one organization. It refuses at zero (new: `reason: 'no-organization'`), at several, and under a wall. - The refusal reuses `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500). - No `packages/spec` edit. - **`bootStack` boots the production `single` shape (D3 / D11).** The harness no longer pins the owner bind off (`autoDefaultOrganization: !!opts.orgContext` is gone). Every boot has the Default Organization, every sign-up is its member, and the harness admin is its owner, as `objectstack dev` / `serve` boot it. - `orgContext` is 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 with `multiTenant: 'posture-only'`. - There is no test-only org-less mode and no escape hatch. - **Unchanged by ruling.** - The 49 gated platform objects keep `isPlatformObjectOutOfTenantAuditScope` until C8. - The lean-install branch is unchanged, with a pin. That is `probeInstallOrganizations` answering `[]` when no organization object is registered. - No C3, C5 or C6 surface, no seeder and no `applyTenantScope` is touched. ## Boot order: fresh `single`, `examples/app-showcase` through `bootStack` (measured) Base `51290bca2c`, implementation head `c49f46bab1`. Seeds: 132 rows over 19 objects. The last two rows were measured with the harness at its old pin. Since this PR, `bootStack` always boots the owner-bind-on row. | step | base | head | | :--- | :--- | :--- | | `AuthPlugin.start()` | no organization | Default Organization inserted: the first insert of the boot (seq 0 of 98) | | `AppPlugin.start()` inline seed | 132 rows, all `organization_id` NULL (`sys_business_unit` 5 of 5 NULL) | 132 rows, 0 NULL, `sys_business_unit` included | | `kernel:ready` | 0 organizations | 1 | | `kernel:listening` | 0 organizations (old harness default). 1 only when a dev admin existed at `kernel:ready`; even then the organization was the 49th insert, after every seed, and 130 of 132 seed rows stayed NULL | 1 | | dev admin, owner bind on (`bootStack` now, always) | org created and admin bound `owner` directly | reconciler binds `member`; the bootstrap logs "promoted the platform admin to owner"; `isPlatformAdmin: true`, positions `platform_admin`, `org_owner` | | dev admin, owner bind off (the old harness default, removed) | no membership, no active organization | `member` of the Default Organization, the session's active organization | ## The derivation rule: `resolveSystemInsertOrganization`, objects in scope | posture | organizations | organization object registered | write carries one | base | head | | :--- | :--- | :--- | :--- | :--- | :--- | | `single` | 1 | yes | no | derived | derived | | `single` | 0 | yes | no | lands NULL (measured, probe) | refused `no-organization` (measured, pins) | | `single` | 2 or more | yes | no | refused `ambiguous-organization` | unchanged | | `single` | 0 | no (lean composition) | no | lands unstamped | unchanged, pinned | | `isolated` / `group` | any | yes | no | refused `walled-posture` | unchanged (measured on a `posture-only` isolated showcase boot: code `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, status 500; the same insert carrying `tenantId` lands stamped) | | any | any | any | yes | stamped with it | unchanged | | the 49 gated platform objects | any | any | no | exempt | unchanged (C8) | ## Acceptance pins (implementation head `4fc2472fd0`, base `ec8f37c890`) `runtime/src/default-organization-boot-invariant.pin.test.ts` boots a real kernel twice: - **Kernel A:** a fresh deployment nobody signed up to. - **Kernel B:** `objectstack dev`, where the dev admin is created after the organization exists. `AppPlugin` is registered before `AuthPlugin` on purpose. | pin | where | base | head | control | | :--- | :--- | :--- | :--- | :--- | | (a) organization before the listener and before the first seed row; an `isSystem` insert with no organization lands stamped | kernel A, 3 cases | red | green | an object declaring `tenancy: { enabled: false }` takes none | | (b) `isolated` / `group` refuse, with the code and status; the insert carrying `tenantId` lands | `objectql` `system-write-organization.test.ts` and the measured isolated boot above | green (since #8844) | green | the carrying insert | | (c) the first admin is the owner, by promotion | kernel B | red | green | `isPlatformAdmin` read back unchanged | | (d) every seed row stamped, `sys_` seeds included | kernel A | red | green | the tenancy-off seed lands with no organization | | (e) lean-install branch unchanged | `objectql` and `metadata-protocol` `[ADR-0131 Q2, held as-is]` cases | green | green | the registered-but-empty case refuses | **Reverse verification of the implementation** (from committed `4fc2472fd0`): 1. The eight implementation source paths were restored to `ec8f37c890` in the tree only. 2. The four packages were rebuilt, and `scripts/ablation-dist-preflight.mjs` confirmed each head marker absent from `dist/`. 3. The pin run: 5 failed and 2 passed, as tabled. 4. A trap restore, proven by `git diff HEAD` empty and per-path blob hashes equal to HEAD. 5. A rebuild with the markers back. **Ablations** (`scripts/ablation-replace.mjs`, each restored and rebuilt): - **The promotion call disabled:** (c) turns red (`member`, not `owner`). - **`com.objectstack.auth` removed from `AppPlugin.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: - **flipped:** the old expectation was the defect C1 fixes, and the pin now holds the new answer. - **re-pinned:** the fixture changed and the subject did not. | file | package | judgement | what changed | | :--- | :--- | :--- | :--- | | `seed-loader-sole-organization-read-failure.test.ts` | metadata-protocol | flipped | no organization, several, or an unprovisioned table no longer write the seed row NULL | | `seed-loader-org-fallback.test.ts` | objectql | flipped | `sys_` seeds take the organization; ambiguity refuses | | `engine-organization-probe-outage.test.ts` (cause 2) | objectql | flipped | the empty probe still is not an outage, but zero organizations now refuses | | `system-write-tenancy-autonumber-split.integration.test.ts` | runtime | flipped | the first-boot write is refused at zero organizations, with the stamped control | | `auth-plugin.test.ts`, "`autoDefaultOrganization: false` opts out" | plugin-auth | flipped | the organization exists anyway | | `seed-loader-engine-schema-fallback.test.ts` | metadata-protocol | re-pinned | the one new engine read is the organization-object registration read | | `seed-loader-existing-records-read-failure.test.ts` | metadata-protocol | re-pinned | the metadata double no longer claims an organization object | | `protocol-publish-package-drafts.test.ts` | objectql | re-pinned | the install holds its Default Organization | | `packages-seed-apply-disclosure.test.ts`, `packages-seed-apply-read-decorations.test.ts`, `http-dispatcher.test.ts` | runtime | re-pinned | each install holds its Default Organization | | `seed-tenancy-autonumber-split.integration.test.ts` | runtime | re-pinned | the split is reproduced from pre-C1 residue written at the driver, since the loader can no longer produce it; a new case pins the refusal | | `auth-plugin.test.ts`, the two `app:seeded` cases | plugin-auth | re-pinned | they delete the organization to reach "no target" | | `status-mirror-cascade.integration.test.ts` | plugin-approvals | re-pinned | The rig registered `sys_organization` but left it unprovisioned and empty, then system-inserted `opportunity`, so CI refused it (`no-organization`). It now 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). The subject, an approval decision cascading as the deciding user, is unchanged. The delegation channel still fires (`afterAll` green). | | `claim-seed-ownership-warm-boot.test.ts` | plugin-security | re-pinned | The rig registered the real identity objects with no auth plugin, so CI refused the `crm_case` seed 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 block | service-automation | re-pinned | The rig composed the identity objects with no auth plugin, so the user-less run's `create_record` was refused (found by the sweep below; CI never reached this suite). The rig now seeds the Default Organization as `org_1`, the member's organization. The NULL-born case also pins the row's derived organization (`org_1`), so the `403` is 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-organization` refusal, adds tolerance in the engine, or skips a case. ## `bootStack` and the dogfood: the 24 re-pinned files At this PR's implementation alone, 24 dogfood files failed; at base they pass. Each was re-pinned on its own cause, to the production `single` shape. Pins that C1 flips flip to the new answer and none is deleted. The shared helper is `leaveOrganization` (new, `test/armed.ts`): it deletes the user's `sys_member` rows 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. | file | cause | what changed | why it keeps the original intent at the production shape | | :--- | :--- | :--- | :--- | | `analytics-adhoc-query-isolation` | its `memory` leg: `driver-memory` refuses a tenant-scoped read (503), and every session now carries the Default Organization | the `memory` leg became an `objectql-strategy` leg on `sqlite-wasm`, with the analytics plugin's `queryCapabilities` withholding native SQL. `Restart-when: #15212 closed` | the file pins isolation under both analytics strategies; the memory driver was only the route to the ObjectQL strategy, and the capability switch reaches it on a driver that answers | | `analytics-contains-membership` | as above | as above | as above | | `analytics-inline-dataset-admission` | as above | as above | as above | | `analytics-inline-dataset-isolation` | as above | as above | as above | | `armed` | the "outside" class was an org-less sign-up; every sign-up is now a member | `orgless`/`orgbound` renamed `outside`/`inside`; the outside principal leaves the organization; the write-floor disarm text names "Keep the principal a member of the organization" | the floor is still measured on a principal with no active organization against one inside it | | `delegated-admin-invite` | it minted its own `slug: 'default'` organization (`DuplicateRecordError`) | reads the boot's Default Organization | same subject inside the deployment's one organization | | `delegation-of-duty` | the delegator is now a member, so the gate reads the delegator's organization's positions (ADR-0091 D3 rule 5), and the fixture's positions had none | `sys_position` / `sys_user_position` rows carry the Default Organization; the session double carries `activeOrganizationId` | same delegation rules, on rows held the way an org-bound deployment holds them | | `invitation-ledger-row-scope` | it minted a second organization (`acme-8095`) while the reconciler binds every sign-up to the Default Organization | uses the Default Organization | the ledger's row scope is measured inside the deployment's one organization | | `membership-actor-attribution` | minted its own `default` organization | reads the boot's | unchanged subject | | `membership-ended-session-revoke` | minted its own `default` organization | reads the boot's | unchanged subject | | `membership-reconciler` | minted its own `default` organization | reads the boot's | unchanged subject: the reconciler binding through the real sign-up pipeline | | `membership-role-vocabulary` | minted its own `default` organization | reads the boot's | unchanged subject | | `org-admin-affordance-reach` | it minted `reach-org` while sign-ups bind to the Default Organization | uses the Default Organization | grades measured in the organization the members are in | | `organization-delete-federated-fixture` | deleting the Default Organization now runs the delete behaviour of every row the deployment owns, the seed included | deletes a second organization (`org-21910`) that the admin owns and no row belongs to | the cascade scan probes every reference on any organization's delete, so the federated anchor is still reached, without a different question attached | | `parent-derived-write-refusal-not-visible` | an org-less principal was the precondition | `boot(inside)`: the outside principal leaves the organization; the inside boot keeps `orgContext: true` | same refusal shape, for a principal outside the organization | | `permission-set-lock-row-provenance` | the harness admin was a `member` | no edit: the harness owner bind | the admin is the deployment's administrator, the Default Organization's owner as `objectstack dev` boots it | | `permission-set-write-through-package-binding` | as above | no edit | as above | | `predicate-write-unreadable-not-matched` | org-less precondition | as `parent-derived-write-refusal-not-visible` | as above | | `sharing-rule-org-less-caller` | the org-less personas, and the harness admin as the org-less platform operator | the org-less personas leave the organization. The operator is a new user holding `admin_full_access` globally 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) | a sharing rule still must not widen reads for a caller with no organization; each caller is now a production shape | | `showcase-permission-projection` | the harness admin was a `member` | no edit | as `permission-set-lock-row-provenance` | | `single-tenant-identity-create` | the old expectation, a `sys_business_unit` with no organization, is the defect C1 fixes | flipped: `organization_id` equals the Default Organization's id | ADR-0057's property (creatable single-tenant, no `VALIDATION_FAILED`) is still the pin | | `sys-file-metadata-write-refusal` | the harness admin was a `member` | no edit | as `permission-set-lock-row-provenance` | | `two-doors-permission` | the harness admin was a `member` | no edit | as `permission-set-lock-row-provenance` | | `write-door-unreadable-is-not-found` | org-less precondition | as `parent-derived-write-refusal-not-visible` | as above | **Files beyond the declared set** (`packages/verify/src/harness.ts` and the 24 files): - `packages/qa/dogfood/test/armed.ts`, which holds the `leaveOrganization` helper. - `packages/verify/src/harness.org-context.test.ts`. The default-boot case now pins the production shape, including the admin's `owner` membership. - `showcase-external-autoconnect.dogfood.test.ts` and `showcase-scope-depth.dogfood.test.ts`: comments only, correcting the description of the removed org-less boot. - `.changeset/15195-verify-bootstack-production-single.md`. - Cross-lane (`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.ts` and `packages/services/service-automation/src/runas-system-stamping.integration.test.ts`. - The tenant-audit census (`content/docs/permissions/tenant-audit-census.mdx`, `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`). After each `main` merge 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 on `9aee4d05f1` with identical results; round 3 changes no file it reads): 1. `scripts/ablation-replace.mjs` (WRAP mode) restored the old pin in `harness.ts`, with a string-literal marker (`REVERSE-15195-OLD-PIN`) the bundler keeps. 2. `@objectstack/verify` was rebuilt, and `ablation-dist-preflight` confirmed the marker present in `dist/`. 3. The mutated leg turned red: - dogfood, the 24 files: 5 files and 14 tests failed, 204 tests passed. The five are exactly the "no edit" rows above. - `harness.org-context.test.ts`: 1 of 5 failed (`expected [ 'member' ] to deeply equal [ 'owner' ]`). 4. The file was restored: blob equal to HEAD and `git diff HEAD` empty. `@objectstack/verify` was rebuilt, and the preflight `--absent` passed. The same leg on `c2f7e36d6e` left the harness unit test green, because its two assertions hold without the owner bind. `05dedeea79` adds the `owner` assertion 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-memory` refuses tenant-scoped reads. Under `single`, every session now carries the Default Organization, so on that driver signed-in reads answer `503` until C8 (#15212, ADR-0131 D8) gives `single` no read predicate. The `plugin-auth` changeset states this. This is not new in production terms. At base, a showcase boot with the owner bind on (the shape `objectstack dev` boots) already answered the platform admin's `GET /data/showcase_category` with `503 SERVICE_UNAVAILABLE` on the memory driver. Only the harness's org-less pin kept the four analytics `memory` variants green. Those variants now run on the SQL in-memory driver, with `Restart-when: #15212 closed` in each file. ## Clause-②: the built entry declarations, base `ec8f37c890` against head - **`@objectstack/objectql`:** - `SystemWriteOrganizationDecision`'s `'no-organization-yet'` becomes `'no-organization-object'`. - `SystemWriteRefusalReason` gains `'no-organization'`. - `resolveSystemWriteOrganization` takes a required `organizationObjectRegistered`. - **`@objectstack/plugin-auth`:** - `EnsureDefaultOrganizationOnceOptions` gains an optional `organizationCreatedByThisProcess`. - `EnsureDefaultOrganizationResult` gains an optional `ownerPromoted` and the `'owner_promotion_failed'` reason. - **`@objectstack/verify`:** no declaration change. `bootStack` now 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 are `minor`, with the BREAKING banner and an ADR-0087 disposition. The runtime changeset is `patch`. Driven offline with this body as the `pull_request` payload (`--event`), `check-changeset-no-major` reads `Clause-②: yes (narrowing)` and passes the level axis. `check-adr-0087-registration` also passes. ## Verification (head `ede1fbd345`, merge base `9f0de32a03`) Every package that depends on `@objectstack/objectql` was run in full. That is 45 packages (`pnpm --filter '...@objectstack/objectql'`); 44 have a `test` script, and `metadata-protocol` was added. A package that does not register `sys_organization` cannot reach the `no-organization` refusal, but the sweep measured every package rather than relying on that argument. In each log, every `SystemWriteOrganizationRequiredError` / `no-organization` match was read; none remain after the fixes. - **Round 3, the three touched packages, at `ede1fbd345`** (plugin-approvals and plugin-security also ran at `b7d0b3469e`, 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 at `ede1fbd345`:** 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. - **The rest of the sweep, at `b7d0b3469e` / `ede1fbd345`:** | package | files | tests | | :--- | ---: | ---: | | plugin-audit | 40 | 641 | | plugin-sharing | 40 | 1,002 | | trigger-record-change | 11 | 114 | | trigger-schedule | 8 | 174 | | service-analytics | 180 | 4,441 (262 skipped) | | service-datasource | 41 | 760 | | service-knowledge | 4 | 49 | | service-messaging | 48 | 534 | | service-settings | 39 | 707 | | service-storage | 43 | 717 | | service-queue | 5 | 77 | | service-sms | 5 | 74 | | rest | 262 | 4,938 (326 skipped) | | hono | 5 | 122 | | http-conformance | 8 | 102 | | downstream-contract | 3 | 31 | | client | 51 | 653 | | client-react | 3 | 34 | | cloud-connection | 41 | 505 | | connector-mcp / -openapi / -rest / -slack | 3 / 4 / 4 / 3 | 23 / 36 / 26 / 10 | | driver-mongodb | 31 (5 skipped) | 690 (182 skipped) | | knowledge-memory / knowledge-ragflow | 1 / 1 | 8 / 10 | | organizations | 11 | 151 | | plugin-dev | 9 | 86 | | plugin-email | 31 | 535 | | plugin-pinyin-search | 2 | 21 | | plugin-webhooks | 15 | 165 | | example-crm / -embed-objectql / -showcase / -todo | 5 / 1 / 33 / 7 | 45 / 2 / 408 / 238 | - **At `9aee4d05f1`.** Round 3 changes no file these read: - dogfood, all 8 shards (`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. - **Typecheck:** green for plugin-approvals, plugin-security and service-automation at `ede1fbd345` (`check:test-typecheck` OK: the plugin-approvals debt ledger is held, the other two ledgers are empty). Earlier: objectql, runtime, metadata-protocol, plugin-auth, verify and dogfood. - **Gates:** `dispatch-gates --commands` derived 112 for this tree (two i18n families joined), all 112 ran with exit 0, and `--ran` reconciled 112 of 112 with recorded exit codes. The roster gates whose list lives in a touched directory all exit 0. `check-single-claim-paths` passed with this PR's context. - **Lint:** `eslint --no-inline-config` over 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 full `pnpm lint` run is CI's. - **Serial:** #22197 and #22215 are still open. Neither shares a file with this PR; no dogfood showcase file and no `showcase-security.ts` is touched. - **`main` since the merge base:** 27 commits, not merged here. `git merge-tree` against it is clean. 16 test files they add or change name the organization object (for example `plugin-security/src/grant-holder-membership-refusal.test.ts`, `service-settings/src/settings-organization-isolation.pin.test.ts` and `dogfood/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 - **The wall's sole-organization seed fallback.** The seed loader still derives under a walled posture when a load names no organization and the install holds exactly one organization. This was read, not measured. The walled inline seed is suppressed, and the per-organization replay always names one. `carrier:` the #15195 claimant. - **The owner-bind ledger.** Its `admin-already-member` second insert (`DUPLICATE_RECORD` on two concurrent triggers, present at base too) is #22099. - **Stale comments.** `packages/plugins/plugin-sharing/src/sharing-service.ts`, `sharing-rule-service.ts` and `sharing-service.test.ts` still describe the harness's `autoDefaultOrganization: false`. They are not edited here (outside the declared set). `carrier:` none named, so this is noted here only. --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f2626c7 commit 34dba5a

59 files changed

Lines changed: 2195 additions & 480 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
Every seed row of an object that carries an organization is stamped with the install's organization, platform-namespace seeds included, or the seed loader refuses the row
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A validity change in the seed loader's write path: no key of the seed schema or of any other metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. A seed dataset that loaded before keeps its authored shape; what changes is the organization its rows carry, that a row with no derivable owner is refused instead of written NULL, and that a row of an object with no organization column is no longer stamped. The package publishes (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered). -->
10+
11+
**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3, D9).
12+
13+
- **The exemption is withdrawn.** When a seed load names no organization (`config.organizationId`), the loader stamps the install's sole organization on every row of an object that carries an `organization_id` column. Seeds of `sys_`, `cloud_` and `ai_` objects used to be exempt as "intentionally global" and landed NULL. There are no platform-global seeds left, so they now carry the organization too: a seeded `sys_business_unit` is the organization's own business unit.
14+
- **No owner, no row.** When the load names no organization and the install holds none, or holds several, a row of an object that carries an `organization_id` column is refused, counted in the result's errors and named in the message. Nothing of it is written. Before this change those rows were written NULL. A row that sets its own `organization_id` still loads.
15+
- **No column, no stamp.** A row of an object with no organization column (`tenancy: { enabled: false }`, or a platform object whose injected column was dropped) is written without one, whether the organization was named by the load or derived. Before this change such a row was stamped anyway, and the engine refused it as an unknown field, so the row was lost.
16+
- **Unchanged.** A load that names its organization (the per-organization replay under a walled posture) stamps it on every row that carries the column, as before. A composition that registers no organization object at all keeps loading its seeds unstamped. Rows written before this change keep their bytes; attributing that residue is ADR-0131 C7's.
17+
18+
**The remedy.** Under the `single` posture the Default Organization exists before seeds load, so a boot seed always has its owner. A load that is refused names the organization it needs: pass it as `config.organizationId` (the organization the seed populates on a walled deployment), or set `organization_id` on the record.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
A system-context insert under the `single` tenancy posture is refused when the install holds no organization, instead of landing with no owner
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing at the engine's system-insert door: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The runtime TypeScript surface moves with it, and is described below rather than prescribed: `resolveSystemWriteOrganization` takes a required `organizationObjectRegistered`, its `no-organization-yet` answer is gone and `no-organization-object` answers the composition with no organization object, and `SystemWriteOrganizationRequiredError.reason` gains `no-organization`. The package publishes (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered). -->
10+
11+
**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9).
12+
13+
- **Before.** Under the `single` posture, in a composition that registers the organization object, a system-context insert on a tenant-scoped object with no organization anywhere landed with `organization_id` NULL when the install held no organization yet. That was the normal state of a first boot: the organization arrived with the first sign-up.
14+
- **Now.** The Default Organization is a boot invariant under `single` (ADR-0131 D3): `@objectstack/plugin-auth` creates it before the application seeds load and before the server accepts a request. An install that registers the organization object and holds none of it therefore has no owner to derive, and the insert is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500, `reason: 'no-organization'`). The message names the missing organization. Nothing is written.
15+
- **Unchanged.** Exactly one organization is derived and stamped. Several organizations, or a walled posture, are refused as before. A write that carries an organization, on the execution context or on the record, is never refused. Objects with no organization column, objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. The platform-namespace objects the tenancy inventory has not admitted keep their per-object exclusion; ADR-0131 C8 retires it. A composition that registers no organization object at all (a lean embedding) still lands the write unstamped.
16+
17+
**The remedy.** On a `single` deployment, the refusal means the Default Organization is missing: restart, and the auth plugin recreates it at boot, or carry the organization on the write (`{ context: { isSystem: true, tenantId } }`, or `organization_id` on the record). A TypeScript caller of `resolveSystemWriteOrganization` passes whether its composition registers the organization object.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
---
4+
5+
Under the `single` tenancy posture the Default Organization is created at boot, before the application seeds and before the server accepts a request, and the first admin of a fresh deployment is its owner
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A boot-order and accept-set change in the auth plugin's runtime: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The `autoDefaultOrganization` constructor option keeps its name and type; what narrows is the state it can produce, described below. The TypeScript surface only grows: an optional `organizationCreatedByThisProcess` on `EnsureDefaultOrganizationOnceOptions`, an optional `ownerPromoted` and the `owner_promotion_failed` reason on `EnsureDefaultOrganizationResult`. The package publishes (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered). -->
10+
11+
**BREAKING** boot and accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3).
12+
13+
- **A boot invariant.** Under the `single` posture (the posture in force, so a degraded walled request counts), the auth plugin's `start()` finds or creates the Default Organization (`slug: 'default'`), with or without a platform admin. Every application plugin starts after it. Before this change the organization was created on `kernel:ready` only once a platform admin existed, so on a fresh deployment it arrived with the first sign-up, after the seeds had loaded with no owner.
14+
- **A failure stops the boot.** If the organization cannot be created, or the store cannot be read, `start()` throws and the deployment does not boot. Before, the bootstrap logged a warning and the deployment served anyway. An install holding several organizations and none with `slug: 'default'` gets no new organization; the boot logs it and continues.
15+
- **`autoDefaultOrganization: false` no longer means "no organization".** It now turns off only the platform admin's owner bind. A host that set it to keep an organization-less `single` deployment gets the Default Organization anyway: under `single` no row is organization-less.
16+
- **Every user belongs to it from the first boot.** Because the organization exists before anyone signs up, the membership reconciler binds every new user (under the `auto` membership policy) to it as `member` the moment they are created, so every session carries it as the active organization. Before, a user created before the first admin carried none. The one-time membership backfill likewise has its target at the first `kernel:ready`.
17+
- **The first admin is the owner.** That includes the first admin, whom the reconciler binds as `member` before the owner bind learns they are the platform admin. While the one-time owner bind is undecided and the Default Organization has no owner, the bootstrap promotes that `member` row to `owner` in place and records the decision as `promoted`. A decided bind, an existing owner, or a membership elsewhere is never touched.
18+
- **The in-memory driver answers `503` until C8.** `@objectstack/driver-memory` refuses tenant-scoped reads, and every session now carries the Default Organization, so under `single` its signed-in reads answer `503` until ADR-0131 C8 (#15212, D8) gives `single` no read predicate. The platform admin met the same refusal before this change, once the Default Organization existed. A SQL driver (`connection: { filename: ':memory:' }` for an in-process store) serves the deployment meanwhile.
19+
20+
**The remedy.** If the boot stops on the Default Organization, make the `sys_organization` insert land: check the datasource's write permission and connectivity, and whether a legacy unique index on `slug` refuses `default`. A host relying on `autoDefaultOrganization: false` for an organization-less `single` deployment has no such deployment any more; run a walled posture if organizations are meant to be absent until created.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
`AppPlugin` starts after the auth plugin when both are composed, so the Default Organization exists before the inline seed loads
6+
7+
`AppPlugin` now declares the auth plugin (`com.objectstack.auth`) among its order-if-present dependencies. Under the `single` posture the auth plugin creates the Default Organization in its `start()` (ADR-0131 D3), and every seed row is stamped with it; the declaration makes the kernel start the auth plugin first instead of relying on the order plugins were registered in. A composition without the auth plugin is unaffected.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/verify': minor
3+
---
4+
5+
`bootStack` boots the production `single` shape: the Default Organization exists from the boot, every sign-up is its member, and the harness admin is its owner
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A change in what the verification harness boots, following the runtime it verifies: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. `BootOptions.orgContext` keeps its name and type; what it asserts is described below. The package publishes (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered). -->
10+
11+
**BREAKING** for fixtures that relied on an organization-less `single` boot, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3, D11).
12+
13+
- **What `bootStack` boots now.** Under `single`, `@objectstack/plugin-auth` creates the Default Organization before the seeds load, so every boot has one, and `bootStack` no longer turns off the platform admin's owner bind. The harness admin is the Default Organization's `owner`, every user a fixture signs up is its `member`, and their sessions carry it as the active organization. This is the shape `objectstack dev` and `objectstack serve` boot. Before, `bootStack` pinned `autoDefaultOrganization: false` and booted a `single` stack with no organization at all, a shape no deployment runs after this release.
14+
- **`orgContext` asserts, it no longer switches.** `orgContext: true` keeps its refusal: the boot fails when the harness admin holds no membership, and it still refuses to compose with `multiTenant`. The bind itself happens on every boot.
15+
- **Unchanged.** `multiTenant` boots a walled posture as before, and the open default-organization bootstrap still abstains under it.
16+
17+
**The remedy.** A fixture that needs a principal outside the organization's `org_member` domain removes that user's membership (an administrator's act a real deployment performs) instead of booting without an organization. A fixture that minted its own `slug: 'default'` organization reads the one the boot created. A fixture that ran on `databaseDriver: 'memory'` and signs a user in meets the memory driver's tenant-scope refusal (`503`) until ADR-0131 C8; run that leg on the SQL in-memory driver.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites
8383
changed, only whether this instrument could see them.
8484

8585
**The expensive failure direction is a keyword.** Sites whose receiver the author
86-
typed `any` have no type to read, and there are 48 of them — just over a fifth
86+
typed `any` have no type to read, and there are 50 of them — just over a fifth
8787
of the population, concentrated in exactly the seed and bootstrap paths this
8888
control exists for. Scoring an unreadable receiver as "not an engine" would have
8989
dropped every one of them silently, with a clean exit and a smaller number that
@@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.
122122

123123
The same holds twice over for the context. An options argument spelled as a
124124
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
125-
forwarding shim cannot, and **52 of the 234 sites are spelled that way**. A
125+
forwarding shim cannot, and **52 of the 236 sites are spelled that way**. A
126126
context resolved from an inline literal or a local `const` can be tested for
127127
`isSystem`; one arriving from a helper call cannot.
128128

@@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
187187

188188
| carried figure | where it survives | this census |
189189
| :--- | :--- | ---: |
190-
| 175 write call sites | quoted in the merged changeset | **234** |
190+
| 175 write call sites | quoted in the merged changeset | **236** |
191191
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable |
192-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 234** decidable, **80** undecidable |
193-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 123 decidably elevated, 0 decidably not, 103 undecidable |
192+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **156 of 236** decidable, **80** undecidable |
193+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 125 decidably elevated, 0 decidably not, 103 undecidable |
194194
| 141 and 132, two independent re-derivations | the card that filed this work | — |
195195

196196
**The differences are not reconciled, and deliberately so.** The old census's
@@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable
200200
at any commit.
201201

202202
Two structural facts do plausibly widen this reading against any hand or regex
203-
one, and both are counted in the generated tables below: the 48 sites reached
203+
one, and both are counted in the generated tables below: the 50 sites reached
204204
through an erased (`any`) receiver, and the 50 that name their object through a
205205
`const` rather than inline. An instrument that read either the way a person does
206206
would report a smaller number and would not say so.
207207

208208
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
209209
figure has no surviving corroboration anywhere in the tree.** This census reads
210-
123 of 234 (53%) as decidably elevated, with 103 more whose elevation is a
210+
125 of 236 (53%) as decidably elevated, with 103 more whose elevation is a
211211
run-time fact — so the claim is neither confirmed nor refuted, and the honest
212212
answer is that a static reading cannot settle it.
213213

214-
⇒ **Cite `2 / 234`, and say what it is**: the sites whose options argument was
214+
⇒ **Cite `2 / 236`, and say what it is**: the sites whose options argument was
215215
READ and holds no tenant context, against a decidably tenancy-enabled object.
216216
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
217217
without tenant context" — **31 further sites** have an options argument this
@@ -223,28 +223,28 @@ cannot read, and they are neither in nor out.
223223

224224
| what | count |
225225
| :--- | ---: |
226-
| write call sites on the application surface | **234** |
227-
| …whose object name is statically decidable | 154 |
226+
| write call sites on the application surface | **236** |
227+
| …whose object name is statically decidable | 156 |
228228
| …whose object name is chosen at run time | 80 |
229-
| …against an object with tenancy ENABLED | 153 |
229+
| …against an object with tenancy ENABLED | 155 |
230230
| …against an object that declares tenancy off | 1 |
231-
| threading a tenant context | 174 |
231+
| threading a tenant context | 176 |
232232
| PROVABLY carrying none (options read, no context key) | **8** |
233233
| …of those, against a decidably tenancy-enabled object | **2** |
234234
| options argument UNREADABLE — may or may not carry one | 52 |
235235
| …of those, against a decidably tenancy-enabled object | 31 |
236-
| threading a decidably ELEVATED (`isSystem`) context | 123 |
236+
| threading a decidably ELEVATED (`isSystem`) context | 125 |
237237
| threading a context that is decidably NOT elevated | 0 |
238238
| threading a context whose elevation is a run-time fact | 103 |
239239

240240
| how the instrument reached the site | count |
241241
| :--- | ---: |
242242
| receiver carried a readable engine type | 186 |
243-
| receiver erased, placed by the object NAME | 28 |
243+
| receiver erased, placed by the object NAME | 30 |
244244
| receiver erased, placed by an `object: string` PARAMETER | 15 |
245245
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
246246

247-
| object name spelled inline | 104 |
247+
| object name spelled inline | 106 |
248248
| object name spelled through a `const` | 50 |
249249
| object name is an `object: string` parameter | 19 |
250250
| object name is some other run-time expression | 61 |
@@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
297297
their values are not compared. The reasoning, and the measurement behind it,
298298
are in `scripts/check-tenant-audit-census.mjs`.
299299

300-
Measured on 2026-10-08 at `0328884e5`.
300+
Measured on 2026-10-08 at `4b40ca31f`.
301301

302302
| corpus scale (not enforced) | count |
303303
| :--- | ---: |
304-
| tracked non-test sources scanned | 617 |
304+
| tracked non-test sources scanned | 618 |
305305
| engine-shaped types recognised | 70 |
306306
| declared objects in the registry | 117 |
307-
| same-named calls subtracted as non-engine | 160 |
307+
| same-named calls subtracted as non-engine | 161 |
308308

309309
{/* END GENERATED: tenant-audit-census */}

0 commit comments

Comments
 (0)