Skip to content

fix(plugin-auth): settle membership under the auto policy at user creation (ADR-0093 D7) - #21813

Merged
objectstack-fleet[bot] merged 18 commits into
mainfrom
claude/issue-21791-membership-create-time
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 18 commits into
mainfrom
claude/issue-21791-membership-create-time

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

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

claude added 5 commits October 5, 2026 03:14
…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>
@github-actions github-actions Bot added the size/l label Oct 5, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/organizations, @objectstack/plugin-auth, @objectstack/types, touching 55 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/plugins/organizations/src/ensure-default-organization.ts, packages/plugins/plugin-auth/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

30 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 9f9510f25e6aa65aa61ce3effb42706fabcab92e.

⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/plugins/organizations/src/ensure-default-organization.ts, packages/plugins/plugin-auth/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 37 pages)
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 21 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 9f9510f25e6aa65aa61ce3effb42706fabcab92e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2a800159759cab12a8abd8faf01528c74b4be097 — the merge of head 5c52ad32f1ce53f9985a4a7107c6270751596ce0 into base 9f9510f25e6aa65aa61ce3effb42706fabcab92e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 9f9510f25e6aa65aa61ce3effb42706fabcab92e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>
@github-actions github-actions Bot added size/xl and removed size/l labels Oct 5, 2026
claude added 5 commits October 5, 2026 06:30
…nators it does not implement

Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com>
…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>
… on an undecided pass

Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com>
…gated bootstrap; declare the widened surface

Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3ed15b1836de49ed4405298196b64199dd7a6f90
Local-runs: none

Inputs read: card #21791 (body and all 5 comments, including the Ruled: on ADR-0093 D7 and three os-dev-reports), the PR body, the file list and the net diff origin/main...3ed15b1836 (28 files), and the check-runs on this head. Class level only. The held detail is not restated.

① Derived judgments

Measured against the ruling: under auto, membership is decided when the user is created, plus one one-time backfill of users who predate the policy. Nothing re-binds a user whose membership was removed.

  • Sign-in seam (accept set narrowed to the ruling): RIGHT. The session-create settle now runs only for a user that the same request created. That user is staged from the adapter's own create result, keyed by request context in a WeakMap, so nothing outlives the request and the store is not read back. A member-less user who signs in later is not re-decided. The dogfood test on a real boot covers both directions: the first session after sign-up carries the organization, and sign-in after removal carries none.

  • Backfill becomes one-time and is recorded in sys_migration as adr-0093-membership-backfill: RIGHT. The latch holds even when the record write fails. An incomplete scan, a deployment with no organization, or an unreadable ledger records nothing. A multi-org refusal is recorded. All of this matches the literal "one-time backfill" in the ruling.

  • Default-org owner bind decided once (adr-0093-default-org-owner-bind), one gate for both postures: RIGHT. The single-org AuthPlugin and walled OrganizationsPlugin both call createEnsureDefaultOrganizationOnce. After the decision, a missing organization is recreated with nobody bound. With no ledger, the owner is bound only on the call that creates the organization. With an unreadable ledger, that call binds nobody. A walled pin test confirms the bind is not repeated.

  • invite-only verdict is recorded as final: RIGHT under D7, noted. A pass under invite-only records reason: policy. A later switch to auto therefore never backfills the users who existed under invite-only. Before this change, the next boot did. This follows from "decided at creation" (those users were decided unbound), and the changeset's "once per deployment" covers it. It is not spelled out, so it is noted for the maintainer in ③ and does not block.

  • Kernel with no ledger: the backfill no longer runs (narrowed): RIGHT, documented. The pass logs a warning and binds nobody. The changeset states this.

  • Public surface of @objectstack/plugin-auth, additive: RIGHT. New exports createEnsureDefaultOrganizationOnce and EnsureDefaultOrganizationOnceOptions. New optional second argument ObjectQLAdapterFactoryOptions.onRecordCreated (a throw inside it is swallowed after the row lands). New options bindOnlyOnCreate and bindOwner. New output-union members 'owner_bind_decided' and 'scan-incomplete'. ensureDefaultOrganization is @deprecated, not removed. Nothing is removed or renamed.

  • Public surface of @objectstack/plugin-auth, backfillMemberships (exported through reconcile-membership.js): WRONG, in what the changelog says. The diff publishes two things here that the changeset does not state:

    1. The limit option used to be a row cap (default 5000). It is now the keyset page size, and the scan is uncapped (default page 500).
    2. The function now needs a reader that honours keyset seek. A reader that cannot seek stalls the walk, and the function returns scan-incomplete and binds nobody. Before, it bound.

    In addition, a direct call to this export is still ungated and re-decides membership on every call. This is the same property for which ensureDefaultOrganization got its @deprecated note, but backfillMemberships carries no such note. The behaviour is correct; the published text is incomplete.

  • @objectstack/organizations: RIGHT. No new export. The wrapper is deprecated by JSDoc. The walled behaviour change is the ruling's.

  • @objectstack/types keysetWalk: RIGHT. A stall is now detected by key equality or a repeated page instead of JS string order. This removes false truncations under database collations and still catches a reader that ignores the predicate. A collation test covers it.

  • Derived artefacts: RIGHT. The census (one new write site), the engine-double ledger, and the durability vocabulary plus its swallow-census copy were all regenerated with their tools. The showcase seed (@objectstack/example-showcase, private) writes persona membership at creation and publishes nothing.

② Semver level

  • The changeset sets @objectstack/plugin-auth to minor, and @objectstack/organizations and @objectstack/types to patch. These levels match what the diff publishes: an additive public surface in plugin-auth, and fixes with no new export in the other two. Nothing is removed, so no (narrowing) or BREAKING arm is owed. The behavioural narrowings are the ruled fix of off-contract re-binding, not a removed author-writable key, export or config field.

  • Clause-②: yes (widening). This is correct, and the changeset and PR body both carry it. yes takes at least minor, and minor is given.

  • The changeset text is short by one surface (blocking). It must state the backfillMemberships change from ①. Suggested Narrowed / public-surface lines:

    • limit is now the page size of an uncapped keyset scan, where it used to be a row cap.
    • The function needs a reader that can seek on id, and a reader that cannot gets scan-incomplete with no binds.
    • A direct call is not gated and re-decides membership. One-time behaviour comes only through the plugin's own wiring.

    The function's JSDoc should say the same about the direct call, as ensureDefaultOrganization's now does.

③ Boundary flags

  • open_questions, round 0, Q1 (rows inserted raw through the engine are no longer bound once the backfill is recorded): answered A, consistent with the recorded ruling. Such a row never passes user creation, and the ruling allows one backfill only. The changeset Narrowed line states this, and the in-tree producer (the showcase personas) now writes its own membership. The card holds no separate Ruled: for Q1 or Q2. The round-1 report's "ruled A" has no record on the card, so this seat answers both on the D7 text, not on that report.
  • open_questions, round 0, Q2 (on upgrade, the first boot runs the backfill once): answered A, consistent with the recorded ruling. This is the literal one-time backfill, and it fails toward the documented D6 behaviour. The changeset Upgrade line states it.
  • Rounds 1 and 2: open_questions is empty. The round-1 out-of-scope item (the walled bootstrap copy) is resolved in this diff, because the walled wiring now uses the shared gate.
  • Escalated to the maintainer (Tier H), not blocking:
    • ADR-0093 D6 still describes the recurring app:seeded re-run. Amending it is a governed edit.
    • The invite-only to auto switch semantics from ① could go in the same amendment.
  • Escalated to the PM seat, a landing precondition outside the diff: the card's claim comment says Clause-②: no, and the round-2 report repeats it. This head declares yes (widening) and adds exports. check-widening-tells reads the claim's line at enqueue. The claim should be revised to Clause-②: yes (widening) before the PR enters the queue.
  • CI on this head is NOT complete. Completed: the claim / single-writer / same-issue / Part-of guards and the Governed Surface Queue Guard, all success. In progress: Build Core, Test Core 1–6, Dogfood Regression Gate 1–3, Dogfood Verify CLI, Build Docs, Temporal Conformance, the Type Check lanes, Lint & Repo Gates, and Check Changeset. This verdict is on the diff. Landing waits for green whatever the verdict.
  • The dev's declared NOT MEASURED item (check:dual-build-cjs-loads, prerequisite not met) is left to CI. It is part of the in-progress set above.

Required to flip to PASS: add the backfillMemberships lines from ② to the changeset, plus the JSDoc note on direct calls. Nothing else in the diff needs to change.

Implemented-by: claude/issue-21791-membership-create-time
Reviewed-by: session_018zT8d8NpiQ1ExhuNd5TxY6

VERDICT: FAIL

…s ungated direct call

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5c52ad32f1ce53f9985a4a7107c6270751596ce0
Local-runs: none

Inputs read: card #21791 (body and all 6 comments: the triage, the claim with its Ruled: on ADR-0093 D7, three os-dev-reports, and the PM claim correction 5993329356 with its two Ruled: lines), the PR body, the file list and the net diff origin/main...5c52ad32f1 (28 files), the earlier record 5993275120 (FAIL on 3ed15b1836), and the check-runs on this head. The only commit since 3ed15b1836 is 5c52ad32f1 (changeset +2 lines, one JSDoc block in reconcile-membership.ts); the rest of the net diff was re-read on this head. Class level only. The held detail is not restated.

① Derived judgments

Measured against the ruling: under auto, membership is decided when the user is created, plus one one-time backfill of users who predate the policy; nothing re-binds a user whose membership was removed.

  • Sign-in seam (accept set narrowed to the ruling): RIGHT. The session-create settle runs only when isCreatedInThisRequest(ctx, userId) holds. The id is staged from the adapter's own create result into a WeakMap keyed by the endpoint context, so nothing outlives the request and nothing is read back. A member-less user signing in later stops at an in-memory lookup. The dogfood test on a real boot covers both directions, and the dev's adapter-staging ablation turned it red.
  • Backfill one-time, recorded as adr-0093-membership-backfill: RIGHT. Latched in-process even if the record write fails; an incomplete scan, a deployment with no organization, or an unreadable ledger records nothing; a multi-org refusal is recorded. Matches the ruling's literal one-time backfill.
  • Default-org owner bind decided once (adr-0093-default-org-owner-bind), one gate for both postures: RIGHT. AuthPlugin and OrganizationsPlugin both call createEnsureDefaultOrganizationOnce; the walled wiring injects claimOrgSeedOwnership directly, the same handoff its old wrapper injected. Once decided, bindOwner: false recreates a missing organization with nobody bound and no handoff. unavailable ledger binds only on the creating call (bindOnlyOnCreate); unreadable binds nobody that call. Only memberCreated or admin_already_in_org latches; no_admin and failed writes stay open.
  • invite-only verdict recorded as final: RIGHT under D7, now stated. The changeset's new Policy switch line says a later switch to auto does not backfill the users who existed under invite-only. This closes the note the earlier record raised.
  • Kernel with no ledger: the backfill does not run (narrowed): RIGHT, documented in the changeset (warn, binds nobody).
  • Public surface of @objectstack/plugin-auth, additive: RIGHT. New exports createEnsureDefaultOrganizationOnce / EnsureDefaultOrganizationOnceOptions (via the new export * in index.ts); ObjectQLAdapterFactoryOptions.onRecordCreated through a new optional, defaulted second argument of createObjectQLAdapterFactory (a throw is swallowed after the row lands); bindOnlyOnCreate / bindOwner options; output-union members 'owner_bind_decided' and 'scan-incomplete'. ensureDefaultOrganization is @deprecated, not removed. Nothing removed or renamed.
  • Public surface of @objectstack/plugin-auth, backfillMemberships: RIGHT (was WRONG on 3ed15b1836). The changeset now states all three changes the diff publishes: limit turns from a row cap (default 5000) into the page size of an uncapped scan; a reader that cannot page by id gets scan-incomplete and binds nobody, where it used to bind; a direct call is not gated by the ledger and re-decides on every call. The JSDoc now says the same and points at runOneTimeMembershipBackfill. This is exactly what the earlier FAIL required.
  • @objectstack/organizations: RIGHT. No new export; the wrapper is @deprecated with the gated replacement named; the walled behaviour change is the ruling's.
  • @objectstack/types keysetWalk: RIGHT. A stall is detected by cursor-key equality or a repeated first row, never by JS string order. This removes false truncation under database collations and still catches a reader that ignores the predicate (it returns the same page). No export changes.
  • Derived artefacts: RIGHT. Tenant-audit census (one new write site) with its prose figures, the engine-double ledger, the durability vocabulary (persistLedgerDecisionRow) and its swallow-census copy, regenerated with their tools. The showcase seed (private package) writes persona membership at creation and publishes nothing.

② Semver level

  • The changeset sets @objectstack/plugin-auth to minor, @objectstack/organizations and @objectstack/types to patch. These match what the diff publishes: additive public surface in plugin-auth, fixes with no new export in the other two. Nothing is removed, so no (narrowing) or BREAKING arm is owed; the behavioural narrowings are the ruled fix of off-contract re-binding, each now stated in the changeset.
  • Clause-②: yes (widening). Correct. The changeset and the PR body both carry it, and the card's claim is now corrected to the same value (5993329356), which resolves the claim mismatch the earlier record escalated. yes takes at least minor; minor is given.
  • The changeset text now covers every published surface the diff changes. No change owed.

③ Boundary flags

  • open_questions, round 0, Q1 (rows inserted raw through the engine stay unbound once the backfill is recorded): answered, A. The card now carries a Ruled: for it (5993329356). The changeset Narrowed line states it, and the one in-tree producer (the showcase personas) writes its own membership.
  • open_questions, round 0, Q2 (the first boot of an upgraded deployment runs the backfill once): answered, A. Ruled: on the card (5993329356); the changeset Upgrade line states it.
  • Rounds 1 and 2: open_questions empty. The round-0 out-of-scope item on DURABILITY_CRITICAL_CALLEES and the round-1 item on the walled bootstrap copy are both resolved inside this diff.
  • Escalated to the maintainer (Tier H), not blocking: ADR-0093 D6 still describes the recurring app:seeded re-run; amending it to the D7 one-time semantics, the invite-only to auto switch included, is a governed edit outside this PR.
  • The earlier PM-seat escalation is closed: the card's claim now reads Clause-②: yes (widening).
  • CI on this head is NOT complete. Completed with success: Check Changeset, Governed Surface Queue Guard, both same-issue / single-writer guards, Spec property liveness, Type Check · source gates, Check Documentation Links, Check PR Size, docs-affected flag, Auto Label, filter. Skipped: Console Pin Gate, Packed-tarball smoke (opt-in). In progress: Build Core, Test Core 1–6, Dogfood Regression Gate 1–3, Dogfood Verify CLI, Build Docs, Temporal Conformance, Type Check · workspace / debt ledger / consumer gates, Lint & Repo Gates. The dev's NOT MEASURED check:dual-build-cjs-loads falls inside that set. This verdict is on the diff; landing still waits for every check to go green.

Implemented-by: claude/issue-21791-membership-create-time
Reviewed-by: session_018zT8d8NpiQ1ExhuNd5TxY6

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 12:06
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 12:06
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 149153c Oct 5, 2026
40 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21791-membership-create-time branch October 5, 2026 12:46
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security(identity): identity-auth.org-membership-team-management clause 5 (membership removal) fails at 316be321e — detail withheld pending maintainer

2 participants