Repository navigation
fix(plugin-auth): settle membership under the auto policy at user creation (ADR-0093 D7) - #21813
Conversation
…ll ledger Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
…fill ledger Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
…eation Also adds the plugin-auth changeset and tightens comments. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
…he new ledger write Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
…mbership-create-time
📓 Docs Drift CheckThis PR changes 3 package(s): 30 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 21 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2a800159759cab12a8abd8faf01528c74b4be097 && git checkout 2a800159759cab12a8abd8faf01528c74b4be097
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9f9510f25e6aa65aa61ce3effb42706fabcab92e 5c52ad32f1ce53f9985a4a7107c6270751596ce0 && git checkout -B drift-repro 9f9510f25e6aa65aa61ce3effb42706fabcab92e && git merge --no-ff 5c52ad32f1ce53f9985a4a7107c6270751596ce0
node scripts/docs-audit/affected-docs.mjs --json 9f9510f25e6aa65aa61ce3effb42706fabcab92e
|
…i-org refusals; scan the backfill in full Also stages the created user from the adapter's create result, so the session seam recognises the creating request without a store read. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
…cabulary copy Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
…nators it does not implement Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
…mbership-create-time
…org wirings; latch one-time decisions in-process Also: the showcase seed writes its personas' membership at creation, and the keyset walk judges a stalled cursor by equality instead of JS string order. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
… on an undecided pass Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
…s organization Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
…mbership-create-time
…gated bootstrap; declare the widened surface Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs read: card #21791 (body and all 5 comments, including the ① Derived judgmentsMeasured against the ruling: under
② Semver level
③ Boundary flags
Required to flip to PASS: add the Implemented-by: VERDICT: FAIL |
…s ungated direct call Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Contract reviewServed-tier: Inputs read: card #21791 (body and all 6 comments: the triage, the claim with its ① Derived judgmentsMeasured against the ruling: under
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…grade through one declared reach table (objectstack-ai#21883) Fixes objectstack-ai#21795 Clause-②: yes (widening) A plain member was offered "Invite User", "Remove Member", "Create Team" and the other org-admin affordances on the organization's member, invitation and team lists, and the server then refused each with 403. The server was right; the buttons had no declared way to ask the same question. This lands ruling A on the card: a declared membership-grade gate on actions, lowered at parse time from one reach table the server door is pinned against. ## What changed - **The reach table** — `MEMBERSHIP_REACH` in `packages/spec/src/identity/membership-reach.ts`, beside the closed name list in `membership-role.ts`. It says which membership grades reach which better-auth organization endpoint. It is a fourth fact beside ADR-0108 D4's three (what names exist, which names mean administrative authority, how a name projects into an identity) and is merged into none of them. Exported from `@objectstack/spec/identity` only: `MEMBERSHIP_REACH`, `MEMBERSHIP_REACH_NAMES`, `membershipReachPredicate`, `lowerRequiresMembershipReach`, and the `MembershipReachEntry` / `MembershipReachName` / `MembershipReachStatement` types. | row | endpoint | statement the door checks | grades | |---|---|---|---| | `invite_member` | `/organization/invite-member` | `invitation:create` | owner, admin, delegated_admin | | `cancel_invitation` | `/organization/cancel-invitation` | `invitation:cancel` | owner, admin | | `update_member_role` | `/organization/update-member-role` | `member:update` | owner, admin | | `transfer_ownership` | `/organization/update-member-role`, setting the creator role | `member:update` plus better-auth's creator-role rule | owner | | `remove_member` | `/organization/remove-member` | `member:delete` | owner, admin | | `create_team` | `/organization/create-team` | `team:create` | owner, admin | | `update_team` | `/organization/update-team` | `team:update` | owner, admin | | `remove_team` | `/organization/remove-team` | `team:delete` | owner, admin | | `add_team_member` | `/organization/add-team-member` | `member:update` | owner, admin | | `remove_team_member` | `/organization/remove-team-member` | `member:delete` | owner, admin | - **The sugar** — `requiresMembershipReach` on `ActionSchema` (`packages/spec/src/ui/action.zod.ts`), in the family of `requiresFeature`. It is enum-checked against the table's row names. At parse time it becomes one `'NAME' in current_user.positions` term per grade, in the names `mapMembershipRole` projects the grades to (`org_owner`, `org_admin`, `delegated_admin`, `eval-user.zod.ts`). It is AND-composed with an explicit `visible` and stripped from the parsed output. `lowerRequiresMembershipReach` mirrors `lowerRequiresFeature` branch for branch: `visible: true` gives the gate alone; `visible: false`, a non-CEL or AST-only `visible`, and a blank `source` are loud parse errors at the key. It runs inside the same `.transform()` as `requiresFeature`, ahead of it, so `features.*` stays the last term. One stage rather than two: a second pipe stage moved twelve `dropped-refinements.baseline.json` entries one level deeper (`in` became `in.in`), measured on the first build. - **The declarations** — `packages/platform-objects/src/identity/*.object.ts`. Re-counted at base `9f9510f25e`: 14 sites carry `requiresFeature: 'organization'`, the seat's count. The ruling counted 12 at `088428fb4`. Thirteen take the key; one takes none: | site | endpoint | key | |---|---|---| | `sys_user.invite_user` | invite-member | `invite_member` | | `sys_member.invite_user` | invite-member | `invite_member` | | `sys_member.add_member` | ObjectStack's platform-admin mount over the vendor's server-only `addMember` (ADR-0068) | none, not grade-gated | | `sys_member.update_member_role` | update-member-role | `update_member_role` | | `sys_member.remove_member` | remove-member | `remove_member` | | `sys_member.transfer_ownership` | update-member-role, role `owner` | `transfer_ownership` (the record predicate is kept and the sugar composes onto it) | | `sys_invitation.invite_user` | invite-member | `invite_member` | | `sys_invitation.cancel_invitation` | cancel-invitation | `cancel_invitation` | | `sys_invitation.resend_invitation` | invite-member with `resend: true` | `invite_member` | | `sys_team.create_team` / `update_team` / `remove_team` | create-team / update-team / remove-team | same names | | `sys_team_member.add_team_member` / `remove_team_member` | add-team-member / remove-team-member | same names | - **The equality test** — `packages/plugins/plugin-auth/src/membership-reach-table.test.ts`, the one new file in plugin-auth, with no source line. For every row it recomputes the grades from the roles map plugin-auth actually hands better-auth: the organization plugin's real constructor options, which are `defaultRoles` plus the `delegated_admin` registration, asked through the vendor's own `authorize`. It also reads, from the installed vendor route source, the statement each endpoint's `hasPermission` checks and the creator-role default. `auth-manager.ts` is untouched. - **The proof** — `packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts`. It boots the showcase with an owner, an `admin`, a `delegated_admin` and a plain `member` in one organization. It reads each principal's served session (`/auth/get-session`), the served flags (`/auth/config`) and the served action metadata of `sys_member`, `sys_invitation`, `sys_team`, `sys_team_member` and `sys_user`. It evaluates the served predicates with `celEngine`, the console's engine, and spells out the expected sets rather than computing them from the table. Door probes show that hidden means refused and shown means admitted. - **The surfaces a new authorable key wakes**, each decided as `requiresFeature` decided it: an `action.json` liveness row (`live`, evidence symbol-anchored to the lowering, no ADR-0054 proof binding, as `requiresFeature` has none); the action metadata form offers the key in its Placement section beside `requiresFeature`; the platform-objects metadata-form catalog gets the key in all four locales (zh-CN / ja-JP / es-ES translated by hand); the regenerated JSON schema / `authorable-surface/ui.json`; `api-surface/identity.json` and `export-origins/identity.json`; the reference docs (`content/docs/references/{ui/action,data/object,kernel/metadata-plugin}.mdx`); `liveness/state-counts/action.md`. `gen:skill-refs` also rewrote two generated skill indexes (see below). - **Pins the declarations move** — `platform-objects.test.ts` (the feature-gate lowering matrix's org rows now pin the composed predicate; the never-survives check covers the new key), `invite-entry-toolbar.test.ts` (the three invite mirrors agree on the composed gate), `action-predicate-sparse-face.test.ts` (the principal binding carries `positions`, as every EvalUser does, so the sweep still reaches the record half), `object-lifecycle-panel-echo-decisions.test.ts` (the translated row-label catalog is 661). - **Changesets** — `@objectstack/spec` `minor` (carries the Clause-② line), `@objectstack/platform-objects` `patch`. The platform-objects dist moved, measured with `npm pack`: `requiresMembershipReach` is in 14 shipped `dist/` files, against a positive control of `requiresFeature` in 14. plugin-auth ships `dist` only, so its new test file publishes nothing. ## Premises (ruling 5993018584 §Premises), verified first 1. **Holds.** Booted the showcase at base (`objectstack dev --fresh --seed-admin`, private port). As the owner I invited a `member`, an `admin` and a `delegated_admin`; each signed up and accepted, and I read `GET /auth/get-session`. `positions`: member `['org_member','everyone']`, admin `['org_admin','everyone']`, delegated_admin `['delegated_admin','everyone']`, owner (seeded admin) `['platform_admin','org_owner',…]`. At base the plain member's served `sys_member.invite_user.visible` was `features.organization != false` (flag `true`), and `POST /auth/organization/invite-member` answered 403 `YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION`. That is the card's reproduction. 2. **Holds.** objectui at the pin `0abd4f9f87`: `RelatedToolbarButton` in `packages/plugin-detail/src/RelatedList.tsx` evaluates each toolbar action's `visible` through `useCondition` (fail-closed). Row actions go through the data-table's `DataTableRowActionItem`. `current_user` is bound to `buildExpressionUser(user)`, which forwards `positions` (`packages/app-shell/src/providers/expressionUser.ts`). 3. **Holds.** `git grep -n defaultRoles origin/main -- packages/plugins/plugin-auth/src/auth-manager.ts` hits at lines 1328, 3041, 3042, 3049 and 3050. better-auth 1.7.3 exports `defaultRoles`, `defaultAc`, `memberAc` and `defaultStatements` from `better-auth/plugins/organization/access`. ## Where the measurement differs from the ruling's sketch - **`resend_invitation` is reached by `delegated_admin`.** A resend is a call to `/organization/invite-member` with `resend: true`, and that door checks `invitation:create`, which `delegated_admin` holds. Measured on the base boot: the delegate's resend answered 200 and its cancel answered 403. So the table row is `invite_member`, and a delegated admin is offered invite and resend, never cancel or any member/team affordance. The ruling's "invite only" is read as "the invite-member endpoint", which covers invite and resend. - **`transfer_ownership` is owner-only**, as the ruling says, through better-auth's creator-role rule rather than a statement: an admin setting `owner` answered 403 `YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER`, and the same admin's role change to `member` answered 200. - **`add_member` takes no key.** Its door is platform-admin standing, so it is no row of the table, and its served predicate carries no grade term (pinned in the dogfood test). It is therefore still offered to a plain member; see Acceptance notes. ## Tests All at head `0a45d2f6e4` unless marked, run through `scripts/pm/os-verify-lock.sh`. Patch round 1 moved one test file, and its readings at the current head `7948aaa454` follow the list. - `@objectstack/spec`: `vitest run --project local` gave 617 files and 18411 tests passed (1 todo); `--project repo` gave 53 files and 902 tests passed. These ran at the head before the liveness-wording and changeset commits, which touch no spec source or test. - `@objectstack/platform-objects`: `vitest run` gave 59 files and 949 tests passed. - `@objectstack/plugin-auth`: `vitest run` gave 121 files and 2538 tests passed (10 skipped). The new file alone has 23 tests. - `@objectstack/dogfood`: `vitest run --project isolated test/org-admin-affordance-reach.dogfood.test.ts` gave 12 tests passed. - `typecheck` for spec, platform-objects, plugin-auth and dogfood all exited 0. - **Ablation**, predicted in writing before the run. I removed the lowering from ActionSchema's transform through `node scripts/ablation-replace.mjs`: anchor 1 then 0, blob `780d2b7a0de4` then `f7c01c42efc0`. The prediction was 4 red / 3 green in the wiring file and 0 red in the lowering file. Observed: `action-requires-membership-reach.test.ts` went 4 red (the key pin, the requiresFeature-composition test, the record-predicate composition test, the `visible: false` refusal), and the remaining 20 of 24 stayed green. Direction: red, as predicted. The restore was proven by blob equals HEAD (`780d2b7a0de4`) and an empty `git diff HEAD`. There is no dist leg: the test imports `./action.zod` from `src`. - Gates: derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (119 commands at `0a45d2f6e4`), run with exits recorded before any pipe, and reconciled with `--ran`: "119 derived, 119 run, 0 NOT-MEASURED, 0 UNRUN". The first pass gave 117 exit 0 and 2 exit 3 PREREQUISITE NOT MET (`check:skill-examples` and `check:dual-build-cjs-loads` read the dist of packages outside this closure). I built those packages, all turbo cache hits, and re-ran both: exit 0. The derivation warned that five gate files changed on main after the merge base (`check-durability-degradation-log-level`, `check-error-code-casing`, `check-type-check-coverage`, `engine-double-contract.pinned.json`, `measure-durability-swallow-family`), so those families ran their merge-base copies. This diff adds no error code, engine double or catch, and CI runs main's copies on the merge ref. - Governed predicate: `node scripts/pm/check-governed-merges.mjs --branch claude/issue-21795-membership-grade-action-gate` gave "0 of 34 path(s) hit the register after 2 generated-artifact lift(s)" and "NOT governed" at `7948aaa454` (33 paths at `0a45d2f6e4`). Both `skills/**` index files are certified PURE REGENERATIONS, byte-equal to `gen:skill-refs` recomputed on this tree. - Patch round 1, at `7948aaa454`: - `metadata-protocol`'s served-`action` key-count pin moves 49 → 50, and its named sample gains `requiresMembershipReach` (`protocol.meta-types-degenerate-derivation.test.ts`, the one file this round touched). - The `@objectstack/metadata-protocol` (214 files / 27745 tests), `@objectstack/rest` (265 / 5075) and `@objectstack/client` (51 / 652) suites are green. - `dispatch-gates --ran` reads "120 derived, 120 run, 0 NOT-MEASURED, 0 UNRUN". - `check:skill-refs` is the gate that demands the two index files. It runs in the required `TypeScript Type Check` aggregate (lane `Type Check · source gates`, no paths filter). With the edits it reports "9 generated files in sync with packages/spec", exit 0. With the two files restored to their merge-base bytes it reports both files "(out of date)" and asks for `pnpm --filter @objectstack/spec gen:skill-refs`, exit 1. Both files were restored afterwards: blob equals HEAD and `git diff HEAD` is empty. ## Acceptance notes - **QA re-run, at the API-composite level.** The screenshot oracle is NOT MEASURED, because the container has no console bundle (`packages/console/dist` is absent and `objectui:build` was not run). - `identity-auth.invitation-scope-gates` A5 ("the UI shows invite affordances only to entitled personas"): the delegated admin is offered `invite_user` on the Members and Invitations lists, and `resend_invitation`. The plain member is offered none. - `identity-auth.org-membership-team-management` A7 ("the gate holds both ways"): the plain member is offered none of the 13 grade-gated affordances. Forged calls are refused: invite 403, create-team 403. The UI half is measured by the dogfood test. - **`sys_user.invite_user` is withheld from the delegated admin by the metadata-plane field mask (ADR-0106), not by this gate.** Its `role` param (`objectOverride: 'sys_member'`) is read as a reference to `sys_user.role`, which that grade cannot read, so `/meta/object/sys_user` drops the whole action, while the door admits the delegate's invite. This predates the change, and the delegate still has the Members and Invitations entries. The dogfood test asserts it is the only unserved site and keeps it out of the gate's verdict. - **`add_member`** (platform-admin door) is still offered to every org member and refused unless the caller is a platform admin. That is the same symptom with a different door, and it is out of this table by the ruling. - **`sys_organization.update_organization` / `delete_organization`** (gated on `multiOrgEnabled`) target grade-gated endpoints (`organization:update` for owner and admin, `organization:delete` for owner) and are outside the ruling's declaration scope. - **`delegated_admin` is not a reserved identity name**, so a tenant-authored `sys_position` of that name would satisfy the invite term client-side. The server still refuses, so this is UI courtesy only. - **Hand-written docs** (`content/docs/ui/actions.mdx`, `content/docs/protocol/objectui/actions.mdx`) describe `requiresFeature` and not yet this key. They are outside this PR's file surface. - **Generated `skills/**` indexes.** `gen:skill-refs` rewrote `skills/objectstack-data/references/_index.md` (70 to 71 lines) and `skills/objectstack-ui/references/_index.md` (60 to 65), because `action.zod.ts` now reaches `identity/eval-user.zod.ts` and, through its type import, `security/permission.zod.ts`. These are generator-owned outputs under the register's `spec-skill-refs` exception. All `SKILL.md` content: 4411 to 4411 lines. `skills/**` in total: 13360 to 13366. - **origin/main re-fetched before opening this PR** (`5e0b489bca`). None of the files this PR changes moved on main, so there was no merge. objectstack-ai#21813 (now on main) touches `auth-manager.ts`, but not the organization roles registration this PR's test pins. ## 维护者速读(草稿) - **改了什么**:组织成员页、邀请页、团队页上的管理按钮(邀请、改角色、移除成员、取消邀请、建团队等),现在只对服务端真正允许的成员等级显示。普通成员不再看到这些点了就报 403 的按钮。 - **为什么改**:裁决 A。按钮和服务端共用一张"哪个等级能调哪个组织接口"的表。这张表放在 spec 里,并由测试钉住与服务端完全一致。 - **风险与代价(含回滚)**:新增一个可选的元数据键,是纯加宽,已有元数据的解析结果不变。代价是多一张需要与 better-auth 同步的表,由测试自动报警。回滚就是回退本 PR,按钮恢复为"只看组织功能开关"。 - **席位意见**: - **你要做的**:无。两个 skills 索引文件由生成器随 spec 变化重新生成,`check-governed-merges` 已核验为纯再生成(生成器豁免),本 PR 不受治理面约束;合约级复核(Clause-②)后由席位按流程落地。 --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…n a user is bound (objectstack-ai#21905) Fixes objectstack-ai#21868 Clause-②: no ## What `TenancyService.defaultOrgId()` (`packages/plugins/plugin-auth/src/tenancy-service.ts`) memoized the default organization id for the life of the process and returned it without checking. The single-org bootstrap recreates a missing `slug='default'` organization on the next `sys_user` insert, under a new id. That same sign-up's membership bind then read the memo and bound the user to the deleted id. Under the `auto` policy membership is decided once, at creation (ADR-0093 D7, the ruling recorded on objectstack-ai#21791 and landed by objectstack-ai#21813), so nothing repairs that bind later. - **Revalidated in the one shared accessor.** Every reader of the default organization goes through `tenancy.defaultOrgId()`: the creation bind and the first-session settle in `AuthManager`, the self-registration grant, the admin create-user bind, the `kernel:ready` backfill, the anonymous form doors in `@objectstack/rest`, the check on organization-scoped form writes in `@objectstack/metadata-protocol`, and the email-template bootstrap. The check lives in the accessor once, with no copy per caller. - **One read.** With a memo present, each call reads `sys_organization` by primary key (`where: { id }`, `limit: 1`). If the row exists, the memo is returned and nothing is re-resolved. - **Gone means re-resolved by the same rule.** On a definite absence the memo is dropped and `resolveDefaultOrgId` runs again, the resolver that set it: the `slug='default'` organization first, else the only organization. The replacement is what a fresh boot would pick. If nothing exists yet, the answer is `null` and the next call resolves again. - **An unanswered read keeps the memo.** A failed read, or a reply that is not a row list, is not evidence that the organization is gone. Dropping the memo on it would bind the next user to no organization, which is also never repaired. Keeping it gives the same answer as before this change. - No `sys_organization` hook, no time-based expiry, no change to the membership policy, no new export. ## Tests - **Red first.** The pin was committed before the fix (`69916d1e12`). There, `src/tenancy-service.test.ts` failed: `usr_second` was bound to `org_old`, expected `org_new` (1 failed, 34 passed). - **Unit (`plugin-auth`, `src/tenancy-service.test.ts`, 39 passed).** Added: - delete and recreate, then create a user through the real `reconcileMembership`; the user binds to the new id; - cost: a memo that still exists costs exactly one `find` per call, by id, and is not re-resolved; - deleted and not yet recreated: `null`, then the replacement; - the replacement is picked slug-first, like the first resolution; - an unanswered read keeps the memo, and the next answered read still revalidates. The existing `memoizes a positive resolution` test asserted that a memoized call issued no query at all, which is the defect. It now asserts exactly one read, by primary key. - **Door pin (new file, `packages/qa/dogfood/test/default-organization-recreated-binds-new-id.dogfood.test.ts`, 1 passed).** A real showcase boot with `orgContext`, the harness switch for the real single-org bootstrap. The admin signs up the first user, who is bound to the bootstrap's organization. The admin deletes it through better-auth's `POST /auth/organization/delete` (200). The next sign-up recreates it, and that user's membership and first session carry the recreated id. - **Ablation** through `scripts/ablation-replace.mjs`, from the committed fix at `13d72b32f9`. The plugin-auth source has not changed since. The memo was made unconditional again (marker `ABLATION_21868_UNCHECKED_MEMO`). Mutation landed: anchor 1 to 0, blob `f0012491afbf` to `5856726a7b5f`. `plugin-auth` was rebuilt and `ablation-dist-preflight` found the marker in 2 built files. - Unit: 4 red, for example `expected 'org_old' to be 'org_new'`. - Dogfood: red, `expected 'org_muvizcge3a2f1lgy' to be 'org_muvizdq45anuk3zq'`. The user was bound to the deleted organization, not the recreated one. - Restored: blob equals HEAD (`f0012491afbf`) and `git diff HEAD` is empty. After a rebuild, `--absent` reported the marker absent from all 14 built files and the tree clean. Unit 39 passed, dogfood 1 passed. - **Packages.** `@objectstack/plugin-auth` full suite at `3e2e917f27`: 123 files, 2575 passed, 10 skipped. Later commits changed only the new test double in plugin-auth, and that file was re-run (39 passed). Typecheck green: tsc, the examples, and `check:test-typecheck` with debt held. `@objectstack/dogfood` typecheck green; `--listFiles` includes the new test. - **Lint, narrowed.** `eslint --no-inline-config --format json` over the 3 touched `.ts` files: 3 files, 0 errors, 0 warnings. Type-aware linting is not enabled in `eslint.config.mjs` (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. The full `pnpm lint` run is left to CI. - **Gates, at `8fe4041e75`.** `dispatch-gates --commands` derived 68 families. All 68 were run and every one exited 0. That includes `check:dual-build-cjs-loads`, measured after building the 8 packages its prerequisite named. `dispatch-gates --ran` over the exit-coded record: `68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN` (a derived zero). `check:where-matcher` first found the new test double reading a combinator key as a field name. The double now refuses one (`8fe4041e75`), and the unit ablation was re-run on that head: 4 red, restored, blob equal to HEAD. ## Acceptance notes - **Cost.** Each `defaultOrgId()` call with a memo now costs one primary-key read of `sys_organization`. That includes each anonymous form request, which reads it to pick the form's organization. When the memo is stale, the call also pays the existing resolution (one or two reads). - **No sentence in `content/docs/**` became false.** `deployment/tenancy-modes.mdx` describes `defaultOrgId()` as the reconciler's target and says nothing about memoization. - **Tooling observation, not filed.** While a refusal message in the new test double contained the text `makeStore:`, `check:where-matcher` reported the matcher as unjudged (`could not lift: ReferenceError: orgs is not defined`). Rewording the message cleared it. The lifter appears to pull in the enclosing factory when its name appears in that text. No carrier. - **Not merged with `origin/main`.** Main is 2 commits ahead (`1e18a0735c`): a spec test-title change and a `platform-objects` action retirement. Neither touches `plugin-auth`, `dogfood` or `verify`. The merge ref is CI's. ## Seat's append: patch rounds 1 and 2 (written by `domain:services` seat 1 from the dev's reports; the dev does not edit this body) - **Patch round 1: the CI red at `8fe4041e75` is root-caused, and it is not this change.** The door pin's `POST /api/v1/auth/organization/delete` answered `500` on CI shard 3/3. The cause is a pre-existing `objectql` defect, filed as objectstack-ai#21910. The cascade relation scan probes a federated object's platform-injected `organization_id` against a remote table that has no such column, so every organization delete fails once a federated object is provisioned. Earlier dogfood files on the same runner had provisioned the showcase federated fixture. Locally that file was absent, so the pin was green. - Reproduced with two runs that differ only in whether the fixture is present. - A control with the revalidation removed fails identically, which clears this PR's code. - **Patch round 2 (route B, the seat's verdict by the four-axis frame): the door pin is moved out** in its own commit (`4303d35abb`), as objectstack-ai#21910's acceptance pin. - The card's "Done when" ("a test deletes and recreates the default organization and creates a user in the same process") is met by the unit pin "a user created after the default organization is deleted and recreated binds to the NEW id" and its ablation (4 red, then restored; blob equals HEAD). - The sections above that describe the door pin are the round-0 record. - **The changeset is unchanged.** It claims no booted pin. The re-derived gate battery at `4303d35abb`: 64 derived, 64 run, all exit 0. - **CI at `4303d35abb`** was cut by the repo's runner starvation (the seat's note `6003048037`). It needs a fresh run. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21791
Clause-②: yes (widening)
What
Under the
automembership 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.sys_migrationledger.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.adr-0093-default-org-owner-bind) runs once. One shared gate (createEnsureDefaultOrganizationOnce) is used by both the single-organization wiring and the walled@objectstack/organizationswiring. 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.error).keysetWalk(@objectstack/types) detects a stalled cursor by key equality or a repeated page, never by string order.@objectstack/plugin-auth(additive,minor):createEnsureDefaultOrganizationOnceandEnsureDefaultOrganizationOnceOptions;ObjectQLAdapterFactoryOptions.onRecordCreatedvia the new optional second argument ofcreateObjectQLAdapterFactory;bindOnlyOnCreate/bindOwneronEnsureDefaultOrganizationOptions; reason members'owner_bind_decided'and'scan-incomplete'.@objectstack/organizationsand@objectstack/typesadd no public export (patch).ensureDefaultOrganization(plugin-auth helper and the organizations wrapper), in favour of the gated factory.invite-onlybinds nobody; multi-org has no automatic binding; the creation paths (sign-up, admin create, import, SSO JIT) still bind underauto.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).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.pnpmgates viadispatch-gates: 121 derived, 121 run, all exit 0 (including the changeset gates againstorigin/main); CI-environment jobs (shards, test completeness, workspace type-check lanes) left to CI.Acceptance notes
app:seededre-run; amending it is a governed (Tier H) edit left to the maintainer.Generated by Claude Code