Repository navigation
Commit f9a8eb8
fix(plugin-security): run the seed-ownership claim whenever a seed settles, on every boot (#21503)
Fixes #21486
Clause-②: no
## What changes
The seed-ownership claim now runs whenever a seed settles
(`app:seeded`), on every boot. Before, it ran only on the boot that
promotes the first platform admin.
- **`security-plugin.ts`**: the `app:seeded` handler
(`claimSeedOwnershipOnSettle`) resolves its claim target itself when no
bootstrap pass of this boot has named one. It does this by asking
`findExistingPlatformAdmin`. When the bootstrap has named a target
(`this.claimTargetAdminUserId`, now an instance field), that target is
used as before. The subscription moved from `start()` to `init()`, so an
in-budget seed fired from an earlier-registered plugin's `start()` is
heard too. The handler reads the engine lazily when it runs.
- **`bootstrap-platform-admin.ts`**: the `already_have_admin` holder
scan (leg A + leg B, ordered, bounded, `truncated` reported) moved
verbatim into one module function, `findPlatformAdminGrantHolder`. The
bootstrap's guard calls it. The new `findExistingPlatformAdmin(ql,
bootstrapPermissionSets)` calls the same function, after the same set-id
read the bootstrap's seed loop makes. It answers `undefined` exactly
where the bootstrap names no target: a walled posture, no
`admin_full_access` among the seeded sets or no stored row for it, or
nobody holding the unscoped grant yet. ⛔ There is no second selection
rule and no second claim path.
- **`claim-seed-ownership.ts`**: the two log lines that promised a
re-run now say what actually happens.
- Claim report: `handed N seeded record(s) to platform admin USER_ID …`.
The prefix consumers match on is kept; it used to say `first admin`.
- PROVISIONAL line: "The claim runs again as each of those sources
settles (`app:seeded`) and hands those rows to the same platform admin".
- Failure and page-cap lines: "until the claim next runs — the next seed
settle (`app:seeded`, on this boot or a later one) or the next
platform-admin promotion".
- The code comment that said `os meta resync` and the bootstrap replay
re-claim is corrected: both short-circuit on `already_have_admin` and
never reach the claim.
- **Docs**: `content/docs/data-modeling/seed-data.mdx` called the
handoff "one-time". It now says the handoff runs again whenever a seed
settles, on every later boot too, and never touches an owned row. No
other sentence in `content/docs/**` (outside `releases/`) or `skills/**`
was made false.
- Changeset: `@objectstack/plugin-security` patch, `Clause-②: no`.
## Measured on the base (`cba429717`), before any change
The rig is a real `ObjectQL` + `SqlDriver` (better-sqlite3) over one
SQLite file, booted twice, with a fresh `SecurityPlugin` each time.
- First boot: seed 3 rows, then sign-up and promotion. The claim hands
all 3 rows over.
- Between boots: one seeded row is deleted (the replay re-inserts it), a
null owner is planted on another row, and one row is owned by somebody
else.
| warm-boot order | handlers that heard `app:seeded` | ownerless after
`app:seeded` | after `kernel:ready` |
|---|---|---|---|
| plugin `start()` → seed settles → `kernel:ready` (the `objectstack
dev` order) | 1 | **2** | **2** |
| seed settles before this plugin's `start()` (app registered first, the
`@objectstack/verify` order) | **0** | **2** | **2** |
| `kernel:ready` → seed settles (over budget) | 1 | 0 | — |
The same reading on a real `ObjectKernel` (registration order
SecurityPlugin→seeder and seeder→SecurityPlugin, with a probe seeder
that settles and triggers `app:seeded` un-awaited, as `AppPlugin` does):
warm-boot ownerless `["c2"]` in both orders.
Log lines on the base warm boot: no `handed … seeded record(s)` line at
all, and `platform bootstrap complete
{"reason":"already_have_admin","adminUserId":"usr_admin_human"}`.
The same probes on the fix: **0 ownerless after `app:seeded` in all
three orders and both kernel orders**. The row owned by somebody else
stays theirs, and one line is logged: `handed 2 seeded record(s) to
platform admin usr_admin_human (1 of 1 eligible object(s) had unowned
rows) — final: …`. The probes were throwaway files and are not
committed.
## The one rule that picks "the existing platform admin"
It is the bootstrap's `already_have_admin` guard, now
`findPlatformAdminGrantHolder`. It returns the first unscoped human
`admin_full_access` grant row, with leg A ordered by grant-row `id`
ascending. `usr_system` never counts. With several admins it answers
**the holder of the grant row whose `id` sorts first**. That is pinned
with a fixture where every other plausible rule answers differently: the
grant `ups_1` goes to `usr_zed`, and `usr_amy` is the older user whose
user id also sorts first. Both the claim and the bootstrap name
`usr_zed`.
## Pins: `claim-seed-ownership-warm-boot.test.ts`, real engine over one
file, booted twice
1. Warm boot, seed settles **before `kernel:ready`** (plugin started
first) → 0 ownerless after `app:seeded`. One claim report `{ claimed: 2,
adminUserId }`, then `already_have_admin` and nothing more claimed.
2. Warm boot, seed settles **before this plugin's `start()`** (app
registered first) → heard by 1 handler, and 0 ownerless.
3. Warm boot, seed settles **after `kernel:ready`** (over budget), using
the target the bootstrap named → 0 ownerless.
4. Several admins → the claim and the bootstrap agree on the
first-grant-id holder.
5. Walled posture → `findExistingPlatformAdmin` names nobody (no claim
ever ran under a wall, and none runs now). Under `single` the same
database answers `usr_zed`.
Each warm-boot case first re-measures the first-boot path as its
control: the in-budget settle with no admin claims nothing and logs
nothing, and the promotion claims 3 (`ownershipClaimed: 3`). Idempotence
is pinned in cases 1–3: the row `c4`, owned by `usr_someone_else`, stays
`usr_someone_else`. `claim-seed-ownership-seed-settle-rerun.test.ts` is
unchanged and green as the double-based control.
## Ablations (direction predicted before each run; the fix was committed
first; the mutation went through `scripts/ablation-replace.mjs` with an
anchor that must hit; restore proven by blob == HEAD and an empty `git
diff HEAD`)
The subject resolves through a relative `./security-plugin.js` import,
so it reads `src/` and no `dist/` rebuild is involved.
- **A1, the handler's self-resolution removed**
(`this.claimTargetAdminUserId` only). Predicted red: cases 1, 2 and 4;
green: 3 and 5. **Observed: exactly that, 3 failed and 2 passed.** For
example, case 1 received `c2: null, c3: null`. Restored blob
`843b795a7603` matched HEAD.
- **A2, the handler effective only after `start()`** (`if (!this.ql)
return;`, which is the subscription back in `start()` in effect).
Predicted red: case 2 only. **Observed: exactly that, 1 failed and 4
passed.** Restore was proven. 1 parent 25797a1 commit f9a8eb8
6 files changed
Lines changed: 706 additions & 223 deletions
File tree
- .changeset
- content/docs/data-modeling
- packages/plugins/plugin-security/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
422 | 422 | | |
423 | 423 | | |
424 | 424 | | |
425 | | - | |
426 | | - | |
427 | | - | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
428 | 431 | | |
429 | 432 | | |
430 | 433 | | |
| |||
0 commit comments