Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
eab2c9b
wip(plugin-auth): settle membership at creation only; one-time backfi…
claude Oct 5, 2026
63c0c15
test(plugin-auth): pin creation-time membership and the one-time back…
claude Oct 5, 2026
f41cb3f
test(dogfood): membership under the auto policy is decided at user cr…
claude Oct 5, 2026
a641515
chore: refresh the tenant-audit census and engine-double ledger for t…
claude Oct 5, 2026
0581a91
Merge remote-tracking branch 'origin/main' into claude/issue-21791-me…
claude Oct 5, 2026
a476ec3
fix(plugin-auth): decide the default-org owner bind once; record mult…
claude Oct 5, 2026
10d5aab
fix(scripts): mirror the new durability seam in the swallow census vo…
claude Oct 5, 2026
3478253
test(plugin-auth): give the backfill fixture membership its primary key
claude Oct 5, 2026
8057347
chore: record the new pinned engine double and refresh the census stamp
claude Oct 5, 2026
cce21c3
test(plugin-auth): the created-in-request engine double refuses combi…
claude Oct 5, 2026
2e64d22
Merge remote-tracking branch 'origin/main' into claude/issue-21791-me…
claude Oct 5, 2026
8a49c6d
fix(plugin-auth,organizations): one owner-bind gate for both default-…
claude Oct 5, 2026
a5a8e31
test(types): the collation case reads the walk's collected ids
claude Oct 5, 2026
e4283d5
test(plugin-auth): the one-time pass latches; the policy pin reads it…
claude Oct 5, 2026
cf3670d
test(dogfood): the showcase demo personas are created into the admin'…
claude Oct 5, 2026
c4f7db3
Merge remote-tracking branch 'origin/main' into claude/issue-21791-me…
claude Oct 5, 2026
3ed15b1
fix(plugin-auth): an unreadable ledger binds nobody; deprecate the un…
claude Oct 5, 2026
5c52ad3
docs(plugin-auth): state backfillMemberships' changed contract and it…
claude Oct 5, 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
28 changes: 28 additions & 0 deletions .changeset/21791-membership-decided-at-creation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/plugin-auth': minor
'@objectstack/organizations': patch
'@objectstack/types': patch
---

Membership under the `auto` policy is settled when the user is created, per ADR-0093 D7.

Clause-②: yes (widening)

- **At creation.** A user created under `auto` is bound to the default organization at creation, and the first session of that creating request carries it. Membership is not decided again when the user signs in later.
- **One-time backfill.** The ADR-0093 D6 backfill of pre-existing users runs once per deployment, and once per process even if its record cannot be written. Its verdict is recorded in the `sys_migration` ledger with id `adr-0093-membership-backfill`. A pass on a deployment with no organization at all records nothing, and the backfill runs again once the default organization is created. If the ledger is missing or cannot be read, the pass does not run and logs a warning. If the record cannot be written, that is logged as an error. `OS_SKIP_MEMBERSHIP_BACKFILL=1` still disables the pass.
- **Default organization owner.** The platform admin is bound as owner of the default organization once, by the bootstrap that first decides it, in both the single-org and the walled organizations wiring. The decision is recorded in the same ledger with id `adr-0093-default-org-owner-bind` and held for the rest of the process even if the record cannot be written. After that, a missing default organization is recreated without binding anyone. To recover, an administrator re-adds members, including themselves, through member management. On a kernel without the ledger, the owner is bound only when the bootstrap creates the default organization. If the ledger exists but cannot be read, that call binds nobody and the next trigger decides.
- **Full scan.** The backfill reads the user and membership tables page by page with no row cap. A scan that cannot read either table in full binds nobody and records nothing. With organizations present but no default target, as in multi-organization deployments, the refusal is recorded.
- **Upgrade.** The first boot of an upgraded deployment runs the backfill once.
- **Unchanged.** `invite-only` binds nobody. Multi-organization deployments get no automatic binding. Users created through sign-up, admin create-user, import or SSO are bound under `auto` as before.
- **Narrowed.** A `sys_user` row inserted straight through the data engine never passes through user creation. Once the backfill is recorded, a later `app:seeded` pass leaves it unbound. That includes users written by a seed that finishes after its inline budget. Code that inserts users this way must write their membership itself; the showcase approval-demo personas now do.
- **`keysetWalk` (`@objectstack/types`).** The walk now decides that a page did not advance only when it gets back the same cursor key or the same page again. It no longer compares keys in JavaScript string order, which disagrees with database collations and could report a healthy walk as truncated.
- **New public surface of `@objectstack/plugin-auth` (additive).**
- `createEnsureDefaultOrganizationOnce` and `EnsureDefaultOrganizationOnceOptions` are the gated bootstrap both wirings call.
- `ObjectQLAdapterFactoryOptions` adds `onRecordCreated`, passed as the new optional second argument of `createObjectQLAdapterFactory`.
- `EnsureDefaultOrganizationOptions` gains `bindOnlyOnCreate` and `bindOwner`.
- `EnsureDefaultOrganizationResult.reason` gains `'owner_bind_decided'`.
- `BackfillMembershipsResult.reason` gains `'scan-incomplete'`.
- Code that switches exhaustively over those reasons sees one more member.
- **`backfillMemberships` (exported) changed behaviour.** Its `limit` option used to cap the rows scanned (default 5000); it is now the page size of a full scan with no cap. The function now needs a reader that can page by `id`; a reader that cannot gets `scan-incomplete` and binds nobody, where it used to bind. A direct call is not gated by the one-time ledger and decides membership again on every call; call it through the one-time pass instead.
- **Policy switch.** Once a pass under `invite-only` is recorded, switching the policy to `auto` later does not backfill the users who existed then; they get membership through invitation or member management.
- **Deprecated, not removed.** The ungated `ensureDefaultOrganization`, both plugin-auth's helper and the `@objectstack/organizations` wrapper, is `@deprecated` in favour of `createEnsureDefaultOrganizationOnce`.
32 changes: 16 additions & 16 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
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 **67 of the 231 sites are spelled that way**. A
forwarding shim cannot, and **67 of the 232 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 | **231** |
| 175 write call sites | quoted in the merged changeset | **232** |
| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **34** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 231** decidable, **77** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 112 decidably elevated, 0 decidably not, 102 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 232** decidable, **78** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 113 decidably elevated, 0 decidably not, 102 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 @@ -207,11 +207,11 @@ 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
112 of 231 (48%) as decidably elevated, with 102 more whose elevation is a
113 of 232 (49%) as decidably elevated, with 102 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 `9 / 231`, and say what it is**: the sites whose options argument was
⇒ **Cite `9 / 232`, 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" — **34 further sites** have an options argument this
Expand All @@ -223,31 +223,31 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **231** |
| write call sites on the application surface | **232** |
| …whose object name is statically decidable | 154 |
| …whose object name is chosen at run time | 77 |
| …whose object name is chosen at run time | 78 |
| …against an object with tenancy ENABLED | 153 |
| …against an object that declares tenancy off | 1 |
| threading a tenant context | 147 |
| threading a tenant context | 148 |
| PROVABLY carrying none (options read, no context key) | **17** |
| …of those, against a decidably tenancy-enabled object | **9** |
| options argument UNREADABLE — may or may not carry one | 67 |
| …of those, against a decidably tenancy-enabled object | 34 |
| threading a decidably ELEVATED (`isSystem`) context | 112 |
| threading a decidably ELEVATED (`isSystem`) context | 113 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 102 |

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

| object name spelled inline | 103 |
| object name spelled through a `const` | 51 |
| object name is an `object: string` parameter | 17 |
| object name is some other run-time expression | 60 |
| object name is some other run-time expression | 61 |

### Subtractions the census could NOT defend — enforced

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-02 at `b668cf134`.
Measured on 2026-10-05 at `34782539c`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 602 |
| engine-shaped types recognised | 67 |
| tracked non-test sources scanned | 605 |
| engine-shaped types recognised | 68 |
| declared objects in the registry | 116 |
| same-named calls subtracted as non-engine | 152 |
| same-named calls subtracted as non-engine | 155 |

{/* END GENERATED: tenant-audit-census */}
17 changes: 9 additions & 8 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 231 |
| Write call sites | 232 |
| Object name statically decidable | 154 |
| Object name chosen at run time | 77 |
| Object name chosen at run time | 78 |
| Against a tenancy-enabled object | 153 |
| Against an object declaring tenancy off | 1 |
| Threading a tenant context | 147 |
| Threading a tenant context | 148 |
| Provably carrying none | 17 |
| …and decidably tenancy-enabled | 9 |
| Options argument unreadable | 67 |
| …and decidably tenancy-enabled | 34 |
| Threading a decidably elevated context | 112 |
| Threading a decidably elevated context | 113 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 102 |

Expand Down Expand Up @@ -90,14 +90,14 @@ 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-02 at `b668cf134`.
Measured on 2026-10-05 at `34782539c`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 602 |
| engine-shaped types recognised | 67 |
| tracked non-test sources scanned | 605 |
| engine-shaped types recognised | 68 |
| declared objects in the registry | 116 |
| same-named calls subtracted as non-engine | 152 |
| same-named calls subtracted as non-engine | 155 |

## Every site

Expand Down Expand Up @@ -135,6 +135,7 @@ Measured on 2026-10-02 at `b668cf134`.
| `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `SystemObjectName.USER` | undecidable | elevated | 1 |
| `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `insert` | `object` | undecidable | elevated | 1 |
| `packages/plugins/plugin-auth/src/member-role-canonical.ts` | `update` | `MEMBER_OBJECT` | undecidable | elevated | 1 |
| `packages/plugins/plugin-auth/src/membership-backfill-ledger.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 |
| `packages/plugins/plugin-auth/src/membership-ended-session.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 2 |
| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `delete` | `m` | undecidable | options unreadable | 1 |
| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `insert` | `m` | undecidable | options unreadable | 1 |
Expand Down
29 changes: 27 additions & 2 deletions examples/app-showcase/src/security/seed-approval-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ async function ensureCredentialAccount(
async function ensureDemoUser(
ctx: ApprovalDemoContext,
user: { id: string; name: string; email: string; phone_number?: string },
organizationId: string | null,
): Promise<string | undefined> {
const existing = await findOne(ctx, 'sys_user', { email: user.email });
if (existing?.id) return String(existing.id);
Expand All @@ -246,6 +247,12 @@ async function ensureDemoUser(
// never provisioned and its surfaces render empty.
await ctx.ql.insert('sys_user', { ...user }, { context: SYS });
ctx.logger?.info?.('[showcase] approval-demo persona provisioned', { email: user.email });
// This raw insert never crosses the platform's user-creation seam, so the
// membership a created user gets there (ADR-0093 D7: decided at creation)
// is written here, once, by the code that creates the persona. A persona
// that already exists is left as it is — its membership was decided when
// it was created.
if (organizationId) await ensureDemoMembership(ctx, user.id, organizationId);
return user.id;
} catch (err) {
// Non-fatal, and the whole persona is lost when it happens: with no row
Expand All @@ -260,6 +267,24 @@ async function ensureDemoUser(
}
}

/** Bind a freshly created persona to the admin's organization as a plain member. */
async function ensureDemoMembership(ctx: ApprovalDemoContext, userId: string, organizationId: string): Promise<void> {
const existing = await findOne(ctx, 'sys_member', { user_id: userId, organization_id: organizationId });
if (existing) return;
try {
await ctx.ql.insert(
'sys_member',
{ id: `mem_showcase_${userId}`, organization_id: organizationId, user_id: userId, role: 'member' },
{ context: SYS },
);
} catch (err) {
ctx.logger?.warn?.('[showcase] approval-demo persona membership failed (persona has no organization)', {
userId,
error: err instanceof Error ? err.message : String(err),
});
}
}

/**
* Launch a signoff flow on a record through the real automation engine, unless
* a pending request already exists for it.
Expand Down Expand Up @@ -350,11 +375,11 @@ export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void {
await assignPositions(ctx, adminId, ADMIN_APPROVAL_POSITIONS, organizationId, 'admin');
// Mei holds no approval position, which makes her a clean *submitter* — a
// requester who is never also one of her own approvers.
const submitterId = (await ensureDemoUser(ctx, PHONE_DEMO_USER)) ?? null;
const submitterId = (await ensureDemoUser(ctx, PHONE_DEMO_USER, organizationId)) ?? null;
// The auditor persona backs the `finance` group of the per-group demo. It
// deliberately holds ONLY `auditor`, so the two groups have distinct
// holders and the request stays open until each group has answered.
const auditorId = await ensureDemoUser(ctx, AUDITOR_DEMO_USER);
const auditorId = await ensureDemoUser(ctx, AUDITOR_DEMO_USER, organizationId);
if (auditorId) await assignPositions(ctx, auditorId, ['auditor'], organizationId, 'auditor');

// [#9308 fixture 1] Make both personas SIGN-INABLE. Provisioning them as
Expand Down
22 changes: 13 additions & 9 deletions packages/plugins/organizations/src/ensure-default-organization.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ensureDefaultOrganization — multi-org flavour of the default-org bootstrap.
* ensureDefaultOrganization — the walled-posture default-org bootstrap with the
* per-org seed-ownership handoff (`claimOrgSeedOwnership`) injected.
*
* The helper itself moved to `@objectstack/plugin-auth` (cloud ADR-0081 D1: the
* open member-management basics own it — single-org mode runs it too, from
* AuthPlugin). This wrapper keeps the multi-org semantics this plugin always
* had by injecting the per-org seed-ownership handoff step
* (`claimOrgSeedOwnership`), which belongs to the org seed pipeline here,
* not to the basics.
*
* See the plugin-auth helper for the full strategy documentation.
* The helper itself lives in `@objectstack/plugin-auth` (cloud ADR-0081 D1).
* `OrganizationsPlugin` no longer calls this wrapper: it calls plugin-auth's
* `createEnsureDefaultOrganizationOnce` with the same handoff injected, which
* decides the owner bind once (ADR-0093 D7). This wrapper is kept for
* existing importers only.
*/

import {
Expand All @@ -32,6 +30,12 @@ export type { EnsureDefaultOrganizationResult };
* Ensure the platform admin has a Default Organization to operate in,
* then hand the org's seeded rows to them. Idempotent (stable slug
* `default` + the admin's existing memberships short-circuit).
*
* @deprecated Use `createEnsureDefaultOrganizationOnce({ claimSeedOwnership:
* claimOrgSeedOwnership })` from `@objectstack/plugin-auth`. Called directly,
* this re-binds an owner whose membership was removed and re-runs the seed
* handoff on every call; the gated factory decides the bind once
* (ADR-0093 D7).
*/
export async function ensureDefaultOrganization(
ql: any,
Expand Down
15 changes: 12 additions & 3 deletions packages/plugins/organizations/src/organizations-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import { Plugin, PluginContext } from '@objectstack/core';
import { claimOrphanOrgRows } from './claim-orphan-org-rows.js';
import type { OrgScopingEngine } from './org-scoping-engine.js';
import { isDefaultOrganizationBootstrapTrigger } from '@objectstack/plugin-auth';
import { ensureDefaultOrganization } from './ensure-default-organization.js';
import { createEnsureDefaultOrganizationOnce, isDefaultOrganizationBootstrapTrigger } from '@objectstack/plugin-auth';
import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js';
import { assertWalledMembershipPolicyDeclared } from './membership-policy-gate.js';
import {
organizationsObjects,
Expand Down Expand Up @@ -468,9 +468,18 @@ export class OrganizationsPlugin implements Plugin {

// ── Default-org bootstrap on kernel:ready + on admin grant ────────
if (this.opts.ensureDefaultOrganization) {
// ADR-0093 D7 — the SAME once-gate the single-org AuthPlugin uses: the
// owner bind (and the seed-ownership handoff that follows it) is decided
// once, recorded in the `sys_migration` ledger and latched in-process;
// after that a missing default organization is recreated without
// binding anyone, so a removed owner's membership stays removed.
const ensureOnce = createEnsureDefaultOrganizationOnce({
logger: ctx.logger,
claimSeedOwnership: claimOrgSeedOwnership,
});
const runEnsure = async () => {
try {
const res = await ensureDefaultOrganization(ql, { logger: ctx.logger });
const res = await ensureOnce(ql);
if (res.defaultOrgCreated) {
ctx.logger.info(
`[org-scoping] created Default Organization ${res.defaultOrgId} for platform admin`,
Expand Down
Loading
Loading