You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 149153c
Browse filesBrowse the repository at this point in the historyBrowse files
fix(plugin-auth): settle membership under the auto policy at user creation (ADR-0093 D7) (#21813)
Fixes#21791
Clause-②: yes (widening)
## What
Under the `auto` membership policy, membership is now decided when the
user is created (ADR-0093 D7), per the maintainer ruling recorded on the
card. Packages: `@objectstack/plugin-auth`,
`@objectstack/organizations`, `@objectstack/types`.
- **Creation.** User creation binds a new user to the default
organization, as before, and the first session minted by that same
request carries it. The request recognises its own new user from the
store adapter's create result, staged per request context; nothing is
read back, and nothing outlives the request. Later sign-ins do not
decide membership.
- **One-time decisions, recorded in the `sys_migration` ledger.**
- The backfill of users who predate the policy
(`adr-0093-membership-backfill`) scans the user and member tables in
full with keyset pages, with no row cap. An incomplete scan binds nobody
and records nothing. A multi-org deployment with no default target is
recorded as such. A deployment with no organization at all defers, and
the pass runs when the default organization is first created.
- The default-organization owner bind
(`adr-0093-default-org-owner-bind`) runs once. One shared gate
(`createEnsureDefaultOrganizationOnce`) is used by both the
single-organization wiring and the walled `@objectstack/organizations`
wiring. Once decided, a missing default organization is recreated with
nobody bound and no seed-ownership handoff. On a kernel without the
ledger, the owner is bound only when the bootstrap creates the
organization; if the ledger exists but cannot be read, that call binds
nobody and the next trigger decides.
- Each decision is latched in-process once acted on, even if writing its
record fails (that failure is logged at `error`).
- **`keysetWalk` (`@objectstack/types`)** detects a stalled cursor by
key equality or a repeated page, never by string order.
- **Showcase.** The approval-demo seed writes its demo personas'
membership when it creates them.
- **New public surface of `@objectstack/plugin-auth` (additive,
`minor`):** `createEnsureDefaultOrganizationOnce` and
`EnsureDefaultOrganizationOnceOptions`;
`ObjectQLAdapterFactoryOptions.onRecordCreated` via the new optional
second argument of `createObjectQLAdapterFactory`; `bindOnlyOnCreate` /
`bindOwner` on `EnsureDefaultOrganizationOptions`; reason members
`'owner_bind_decided'` and `'scan-incomplete'`.
`@objectstack/organizations` and `@objectstack/types` add no public
export (`patch`).
- **Deprecated, not removed:** the ungated `ensureDefaultOrganization`
(plugin-auth helper and the organizations wrapper), in favour of the
gated factory.
- **Recovery:** after a default organization is recreated with nobody
bound, an administrator re-adds members, including themselves, through
member management.
- **Unchanged.** `invite-only` binds nobody; multi-org has no automatic
binding; the creation paths (sign-up, admin create, import, SSO JIT)
still bind under `auto`.
- **Derived artefacts,** regenerated with their tools: the tenant-audit
census (one new engine write site) with its prose figures, the engine
test-double ledger, the durability gate vocabulary
(`persistLedgerDecisionRow`) and its swallow-census copy.
## Tests
Measured at `3ed15b1836`:
- `@objectstack/plugin-auth`: typecheck green; 121 files, 2541 passed,
10 skipped.
- `@objectstack/organizations`: typecheck green; 9 files, 131 passed.
- `@objectstack/types`: typecheck green; 23 files, 706 passed
(`--project local`).
- Dogfood (real showcase boot): membership decided at creation; demo
personas hold an organization; neighbouring persona, approval-override
and membership-revoke suites — 5 files, 10 passed.
- New coverage: the one-time ledger (first pass, recorded verdict,
multi-org refusal, deferral, pagination past one page, incomplete scan,
unreadable ledger, failed record then later triggers), creation
recognised only within its own request (including a different
pre-existing user in a creating request), restart after a membership
change, the walled owner-bind gate, recreate-without-bind, and keyset
stall detection under non-JS collations.
- Ablations through `scripts/ablation-replace.mjs`, each restored to
HEAD: the request-scoped creation check, the in-process latches (owner
bind, backfill), the keyset equality check, and the persona membership
write each turned their test red.
- `pnpm` gates via `dispatch-gates`: 121 derived, 121 run, all exit 0
(including the changeset gates against `origin/main`); CI-environment
jobs (shards, test completeness, workspace type-check lanes) left to CI.
## Acceptance notes
- **Upgrade boot.** The first boot of this version has no record, so
both one-time passes run once; after that they are recorded.
- **Users inserted directly through the data engine** (including seeds
that finish after their budget) never cross user creation and stay
unbound once the backfill is recorded. Seeds should create users through
the creation seam or write membership themselves, as the showcase seed
now does.
- **ADR-0093 D6's text** still describes the recurring `app:seeded`
re-run; amending it is a governed (Tier H) edit left to the maintainer.
---
_Generated by [Claude
Code](https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Membership under the `auto` policy is settled when the user is created, per ADR-0093 D7.
8
+
9
+
Clause-②: yes (widening)
10
+
11
+
-**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.
12
+
-**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.
13
+
-**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.
14
+
-**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.
15
+
-**Upgrade.** The first boot of an upgraded deployment runs the backfill once.
16
+
-**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.
17
+
-**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.
18
+
-**`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.
19
+
-**New public surface of `@objectstack/plugin-auth` (additive).**
20
+
-`createEnsureDefaultOrganizationOnce` and `EnsureDefaultOrganizationOnceOptions` are the gated bootstrap both wirings call.
21
+
-`ObjectQLAdapterFactoryOptions` adds `onRecordCreated`, passed as the new optional second argument of `createObjectQLAdapterFactory`.
22
+
-`EnsureDefaultOrganizationOptions` gains `bindOnlyOnCreate` and `bindOwner`.
- Code that switches exhaustively over those reasons sees one more member.
26
+
-**`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.
27
+
-**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.
28
+
-**Deprecated, not removed.** The ungated `ensureDefaultOrganization`, both plugin-auth's helper and the `@objectstack/organizations` wrapper, is `@deprecated` in favour of `createEnsureDefaultOrganizationOnce`.
Copy file name to clipboardExpand all lines: content/docs/permissions/tenant-audit-census.mdx
+16-16Lines changed: 16 additions & 16 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.
122
122
123
123
The same holds twice over for the context. An options argument spelled as a
124
124
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
125
-
forwarding shim cannot, and **67 of the 231 sites are spelled that way**. A
125
+
forwarding shim cannot, and **67 of the 232 sites are spelled that way**. A
126
126
context resolved from an inline literal or a local `const` can be tested for
127
127
`isSystem`; one arriving from a helper call cannot.
128
128
@@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
187
187
188
188
| carried figure | where it survives | this census |
189
189
| :--- | :--- | ---: |
190
-
| 175 write call sites | quoted in the merged changeset |**231**|
190
+
| 175 write call sites | quoted in the merged changeset |**232**|
191
191
| 24 carrying no tenant context | quoted in the merged changeset |**9** provable and tenancy-enabled; **34** 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 231** decidable, **77** undecidable |
193
-
| 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 |
192
+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card |**154 of 232** decidable, **78** undecidable |
193
+
| 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 |
194
194
| 141 and 132, two independent re-derivations | the card that filed this work | — |
195
195
196
196
**The differences are not reconciled, and deliberately so.** The old census's
@@ -207,11 +207,11 @@ would report a smaller number and would not say so.
207
207
208
208
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
209
209
figure has no surviving corroboration anywhere in the tree.** This census reads
210
-
112 of 231 (48%) as decidably elevated, with 102 more whose elevation is a
210
+
113 of 232 (49%) as decidably elevated, with 102 more whose elevation is a
211
211
run-time fact — so the claim is neither confirmed nor refuted, and the honest
212
212
answer is that a static reading cannot settle it.
213
213
214
-
⇒ **Cite `9 / 231`, and say what it is**: the sites whose options argument was
214
+
⇒ **Cite `9 / 232`, and say what it is**: the sites whose options argument was
215
215
READ and holds no tenant context, against a decidably tenancy-enabled object.
216
216
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
217
217
without tenant context" — **34 further sites** have an options argument this
@@ -223,31 +223,31 @@ cannot read, and they are neither in nor out.
223
223
224
224
| what | count |
225
225
| :--- | ---: |
226
-
| write call sites on the application surface |**231**|
226
+
| write call sites on the application surface |**232**|
227
227
| …whose object name is statically decidable | 154 |
228
-
| …whose object name is chosen at run time |77|
228
+
| …whose object name is chosen at run time |78|
229
229
| …against an object with tenancy ENABLED | 153 |
230
230
| …against an object that declares tenancy off | 1 |
231
-
| threading a tenant context |147|
231
+
| threading a tenant context |148|
232
232
| PROVABLY carrying none (options read, no context key) |**17**|
233
233
| …of those, against a decidably tenancy-enabled object |**9**|
234
234
| options argument UNREADABLE — may or may not carry one | 67 |
235
235
| …of those, against a decidably tenancy-enabled object | 34 |
236
-
| threading a decidably ELEVATED (`isSystem`) context |112|
236
+
| threading a decidably ELEVATED (`isSystem`) context |113|
237
237
| threading a context that is decidably NOT elevated | 0 |
238
238
| threading a context whose elevation is a run-time fact | 102 |
239
239
240
240
| how the instrument reached the site | count |
241
241
| :--- | ---: |
242
-
| receiver carried a readable engine type |183|
242
+
| receiver carried a readable engine type |184|
243
243
| receiver erased, placed by the object NAME | 28 |
244
244
| receiver erased, placed by an `object: string` PARAMETER | 15 |
245
245
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
246
246
247
247
| object name spelled inline | 103 |
248
248
| object name spelled through a `const`| 51 |
249
249
| object name is an `object: string` parameter | 17 |
250
-
| object name is some other run-time expression |60|
250
+
| object name is some other run-time expression |61|
251
251
252
252
### Subtractions the census could NOT defend — enforced
253
253
@@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
297
297
their values are not compared. The reasoning, and the measurement behind it,
0 commit comments