Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
328b304
wip(objectql): refuse a single-posture system insert with no organiza…
claude Oct 7, 2026
599b051
wip(plugin-auth,metadata-protocol,runtime): Default Organization boot…
claude Oct 7, 2026
38d3a05
wip(tests): re-pin seed and system-write tests to ADR-0131 D3/D9
claude Oct 7, 2026
2849664
wip(runtime,plugin-auth,metadata-protocol): changesets; re-pin first-…
claude Oct 7, 2026
9ee45a1
Merge remote-tracking branch 'origin/main' into claude/issue-15195-de…
claude Oct 7, 2026
8b7db6b
Merge remote-tracking branch 'origin/main' into claude/issue-15195-de…
claude Oct 8, 2026
c49f46b
wip(plugin-auth,metadata-protocol,runtime): keep the boot invariant i…
claude Oct 8, 2026
558793d
wip(runtime): the owner pin boots the dev-admin shape, where the reco…
claude Oct 8, 2026
2fd5764
Merge remote-tracking branch 'origin/main' into claude/issue-15195-de…
claude Oct 8, 2026
955a951
wip(changesets): column-aware seed stamp, every user bound from the f…
claude Oct 8, 2026
fff1f03
wip(tests,docs): pin the new engine doubles to the engine contracts; …
claude Oct 8, 2026
3e799dc
wip(tests): the seed-stamp fake declares only the verbs the loader ca…
claude Oct 8, 2026
a5cf44a
wip(tests): the pin's probe resolves the engine by its contract type;…
claude Oct 8, 2026
4fc2472
Merge remote-tracking branch 'origin/main' into claude/issue-15195-de…
claude Oct 8, 2026
cd7f73b
Merge remote-tracking branch 'origin/main' into claude/issue-15195-de…
claude Oct 8, 2026
fbc0e91
docs(audits): the tenant-audit census regenerated after the main merg…
claude Oct 8, 2026
5c97ee0
wip(verify): bootStack boots the production single shape; orgContext …
claude Oct 8, 2026
cee4376
wip(dogfood): re-pin the 24 fixtures that encoded an org-less single …
claude Oct 8, 2026
98a89bc
wip(changesets,dogfood): the verify changeset; the memory-driver cons…
claude Oct 8, 2026
c2f7e36
Merge remote-tracking branch 'origin/main' into claude/issue-15195-de…
claude Oct 8, 2026
05dedee
test(verify): the default-boot case pins the owner bind — the admin o…
claude Oct 8, 2026
4b40ca3
Merge remote-tracking branch 'origin/main' into claude/issue-15195-de…
claude Oct 8, 2026
9aee4d0
docs(audits): the tenant-audit census regenerated after the second ma…
claude Oct 8, 2026
b7d0b34
test(plugin-approvals,plugin-security): two engine rigs boot the prod…
claude Oct 8, 2026
ede1fbd
test(service-automation): the SecurityPlugin flip rig holds the Defau…
claude Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/15195-metadata-protocol-seed-organization-stamp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/metadata-protocol': minor
---

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

Clause-②: yes (narrowing)

<!-- 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). -->

**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3, D9).

- **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.
- **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.
- **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.
- **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.

**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.
17 changes: 17 additions & 0 deletions .changeset/15195-objectql-no-organization-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/objectql': minor
---

A system-context insert under the `single` tenancy posture is refused when the install holds no organization, instead of landing with no owner

Clause-②: yes (narrowing)

<!-- 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). -->

**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9).

- **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.
- **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.
- **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.

**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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/plugin-auth': minor
---

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

Clause-②: yes (narrowing)

<!-- 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). -->

**BREAKING** boot and accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3).

- **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.
- **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.
- **`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.
- **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`.
- **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.
- **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.

**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.
7 changes: 7 additions & 0 deletions .changeset/15195-runtime-app-plugin-after-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@objectstack/runtime': patch
---

`AppPlugin` starts after the auth plugin when both are composed, so the Default Organization exists before the inline seed loads

`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.
17 changes: 17 additions & 0 deletions .changeset/15195-verify-bootstack-production-single.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/verify': minor
---

`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

Clause-②: yes (narrowing)

<!-- 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). -->

**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).

- **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.
- **`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.
- **Unchanged.** `multiTenant` boots a walled posture as before, and the open default-organization bootstrap still abstains under it.

**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.
36 changes: 18 additions & 18 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites
changed, only whether this instrument could see them.

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

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

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

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **234** |
| 175 write call sites | quoted in the merged changeset | **236** |
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 234** decidable, **80** undecidable |
| 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 |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **156 of 236** decidable, **80** undecidable |
| 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 |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

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

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

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

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

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

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

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

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

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

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