Repository navigation
fix(plugin-auth): the owner-bind gate decides once, so a first boot logs no refused sys_migration insert - #22336
Conversation
…fore its first await
createEnsureDefaultOrganizationOnce re-enters itself on a first boot: the
owner write makes plugin-security grant organization_admin, that grant insert
is a bootstrap trigger, and the inner call read the latch as undecided,
recorded 'admin-already-member' first, and left the outer call's accurate
record ('promoted' or 'bound', with the organization id) to be refused by the
sys_migration primary key with a warning and a stack on every first boot.
The gate now sets an in-flight mark synchronously before its first await. A
call that finds it set runs ensure with bindOwner false and records nothing;
the deciding call records its own outcome once and clears the mark on the way
out, so a call that did not act leaves the decision to the next trigger.
Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…e-entry and a concurrent trigger Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…gger arriving in flight Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…s — each row written once, no warning names it Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…ht mark Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…gine predicate; record the new pinned doubles check:engine-double-contract asks a new findOne double to route through assertEngineFindOnePredicate and the RETAINED ledger to learn the three new pinned (file, verb) rows (node scripts/check-engine-double-contract.mjs --write). Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 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 ab0e70a3bb8f84d2e21c2457015d214f0e0da0db && git checkout ab0e70a3bb8f84d2e21c2457015d214f0e0da0db
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 28bff18d0c4013db86d61eba87c739c3145e17fd 82ae109f5ee4a42422e630ad626757e2fa110ccf && git checkout -B drift-repro 28bff18d0c4013db86d61eba87c739c3145e17fd && git merge --no-ff 82ae109f5ee4a42422e630ad626757e2fa110ccf
node scripts/docs-audit/affected-docs.mjs --json 28bff18d0c4013db86d61eba87c739c3145e17fd
|
Fixes #22099
Clause-②: no
The one-time owner-bind gate (
createEnsureDefaultOrganizationOnce,packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts) now marks its decision in flight synchronously, before its firstawait. A call that finds the decision in flight runsensure(ql, { bindOwner: false })and records nothing. The deciding call records its own outcome once. A call that did not act (no_admin, a refused write) clears the mark on the way out, so the next trigger still decides. This is the direction triage ruled (6042758404, option A). There is no promise chain, no ledger upsert or insert-if-absent, and no catch-all.Triage folded #22102 into this card as a duplicate (same row, same mechanism); #22102 remains open, and its disposition is the seat's.
Re-measured on the landed shape (
origin/main28bff18d, after PR #22186)The gate still re-enters itself. The Default Organization boot invariant (ADR-0131 D3) changes which write re-enters it: the outer call now PROMOTES the reconciler-written
memberrow instead of insertingsys_member.The chain on a first boot, outermost first (probe stacks through
bootStack):kernel:ready,security-plugin.ts:4701runBootstrap,bootstrap-platform-admin.ts:1170promote,:407insertsys_user_permission_set;auth-plugin.ts:1255,runEnsure:1224: the OUTER call;ensure-default-organization.ts:498,promoteReconciledMemberToOwner:379,ql.update('sys_member', { role: 'owner' });security-plugin.ts:5145,reconcileOrgAdminGrantauto-org-admin-grant.ts:777,:238insertsys_user_permission_set;auth-plugin.ts:1255again: the INNER call. It records{ outcome: 'admin-already-member' }first. The outer call's{ outcome: 'promoted', organizationId }is then refused by the primary key.os serve,examples/app-crm,NODE_ENV=development)sys_migrationfailed-insert lines, before:memory:bootStack(dogfood harness, empty app)adr-0093-default-org-owner-bindinsert attempts, before:memory:Persisted owner-bind
details(file DB, read back):{"outcome":"admin-already-member"}, whilesys_memberheld the admin asownerof the one default organization;{"outcome":"promoted","organizationId":"org_muzty5e5bxpe2dtt"}, which is the organization of the admin'sownerrow.Walled first boot (
bootStack,isolated,OrganizationsPluginmounted), measured onmain: ONE owner-bind insert,{"outcome":"bound","organizationId":…}matching the operator'ssys_memberrow, and no warning. Under a wall the grant-insert arm ofisDefaultOrganizationBootstrapTriggeris retired, so this chain does not re-enter the walled wiring today. The walled wiring calls the same gate and gets the same rule.Pins
packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts: the gate over a fake engine whosesys_migrationinsert refuses a duplicate id and whosesys_memberwrites re-enter the gate.boundonce, with the orgsys_memberpoints at;promotedonce;recordLedgerDecision'serrorbranch.packages/plugins/organizations/src/walled-default-org-owner-bind-in-flight.pin.test.tssits besidewalled-default-org-self-registrant.pin.test.ts. Asys_useremail_verifiedupdate reachesOrganizationsPlugin's own bootstrap middleware while the bind is in flight. The ledger is written once,bound, with the orgsys_memberpoints at.packages/qa/dogfood/test/sys-migration-boot-ledger-once.dogfood.test.tsis the family's enumeration pin. It boots a fresh database twice over one file throughbootStack. The ids come from the table's own rows, and each one must have been inserted exactly once. No line atWARNor above may namesys_migration. The owner-bind row must read the deciding call's outcome with its owner's organization. The control requires the capture to have parsed anINFOline naming the ledger.detailsreadbound. On the landed full boot the accurate outcome ispromoted(ADR-0131 D3 binds the admin asmemberbefore the gate runs). So the dogfood pin assertspromoted, and theboundarm is pinned at the unit and walled layers, where the gate inserts the owner row.Ablation (at
d286091a08, fix committed first; delete the synchronous mark)The mark (
deciding = true;) was deleted withscripts/ablation-replace.mjs: anchor 1 → 0, blob27c001b5→635eb0e8. plugin-auth was rebuilt (exit 0), andablation-dist-preflight --absent 'deciding = true'passed.expected [ …(2) ] to have a length of 1 but got 2.adr-0093-default-org-owner-bind: 2against 1;boot 1: expected [ Array(1) ] to deeply equal [](the warning);{ outcome: 'admin-already-member' }(the lost update). The control was green.The restore was proved: blob
27c001b5matches HEAD andgit diff HEADis empty. plugin-auth was rebuilt and the preflight founddeciding = truein dist again.git status --porcelainwas empty.A first ablation leg deleted only the
|| decidingread. Its JS reached dist, but the build exited 1 at the DTS step (TS6133:decidingwas never read). That leg is VOIDED as a reading, and the leg above replaces it. Its colours were the same.Verification
The package runs below are at
33520be606. The final commit82ae109f5echanged only the plugin-auth pin'sfindOnedouble and the generatedscripts/engine-double-contract.pinned.json, and the runs it can move were repeated there. The gate union ran at82ae109f5e.pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2: 130 files, 2662 passed, 10 skipped. At82ae109f5ethe new file re-ran 5/5 andcheck:test-typecheckre-ran OK.pnpm --filter @objectstack/plugin-auth typecheck: exit 0.check:test-typecheckheld 10 files / 94 errors, unchanged.pnpm --filter @objectstack/organizations exec vitest run --maxWorkers=2: 12 files, 152 passed. Itstypecheckexited 0.pnpm --filter @objectstack/dogfood typecheck: exit 0. The enumeration pin: 4/4.Dogfood Regression Gate. Only the new file ran here. No importer ofplugin-authowes a test: the diff changes a function body and a module comment, and no exported declaration.turbo run build --filter='@objectstack/plugin-auth^...' --filter='@objectstack/organizations^...' --filter='@objectstack/dogfood^...' --concurrency=1: 63/63.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 76 commands from this branch's change set at82ae109f5e. All 76 ran there and exited 0.--ranreconciled them:76 derived famil(ies) accounted for — 76 run, 0 NOT-MEASURED. Sample verdict lines:check-engine-double-contract: OK — 986 pinned, 129 in the DEBT ledger, 3 exempt.check-nul-bytes: OK (scanned 10308 text file(s) …; no raw ASCII control bytes).check-test-source-alias OK — 73 packages with tests scanned; 60 registered as still resolving a workspace dep through dist/ …OK: 30 package(s) read outside themselves, all declared …(check:cross-package-test-inputs).33520be606had two non-zero lines.check:engine-double-contractwas red: the new findOne double was not routed throughassertEngineFindOnePredicate, and the ledger lacked the new rows. Repaired in82ae109f5e.check:dual-build-cjs-loadsprintedPREREQUISITE NOT MET(8 packages had no dist). It was NOT MEASURED then, and it exited 0 after those dists were restored from the turbo cache.pnpm lint): 4 files, 0 errors and 0 warnings by--format json. Each file is matched byeslint.config.mjs(--print-config), and none is ignored.eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot move the verdict on any untouched file.Acceptance notes
scripts/engine-double-contract.pinned.jsongains three generated rows (--write, grow-only coverage ledger) for the two new pinned doubles. This file is outside the claim's declared surface, and the gate requires it for any new pinned double.ensurewithbindOwner: falseand may still CREATE the default organization while the deciding call waits. The decider then sees an existing organization underbindOnlyOnCreateand binds nobody.ensureDefaultOrganization's org creation was already not concurrency-safe (two concurrent calls can both insert one). Every served kernel composes the ledger (PlatformObjectsPlugin), andsinglecreates the organization at boot.admin-already-memberare not rewritten. Nothing readsdetails:readLedgerDecisionanswers existence only.Generated by Claude Code