Skip to content

Commit f9a8eb8

Browse files
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. ⚠️ The first A2 attempt was a no-op: the tool refused it because the replacement still contained the anchor, so nothing ran. It was re-anchored and run once more. That re-run is the reading above. ## Verification, on the final head `1f33f8ee5` (`origin/main` merged after PR #21488 landed) - `pnpm --filter @objectstack/plugin-security run test --maxWorkers=2` (the package `test` script) → **164 files passed, 3527 passed, 45 skipped**. - `pnpm --filter @objectstack/plugin-security run typecheck` → green. That includes `check:test-typecheck` over `tsconfig.test.json`, which compiles the new test file. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths) gives 94 commands against merge base `555504711`. All 94 ran with **exit 0**. `--ran` reconciles: **94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN**. - On the pre-merge tree, `check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n` refused with PREREQUISITE NOT MET (exit 3, nothing measured). After a full build they measured green on this head. - `check:slot-lookup` caught a real erasure in my first cut (`let ql: any` around `getService`). It is typed now (`IObjectQLEngine | undefined`), in commit `1f33f8ee5`. - Lint, as a proven narrowing rather than the repo-wide run (CI owns `pnpm lint`): eslint `--no-inline-config --format json` over the 4 touched TS files gives 4 files, 0 errors and 0 warnings. Each file resolves a config and none is ignored (an ignored file would warn). `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. - NOT MEASURED locally, and left to CI by design: the Test Core shards, the Dogfood Regression Gate, Dogfood Verify CLI, Build Core, Build Docs, Temporal Conformance and the workspace Type Check lanes. - `origin/main` has moved 3 commits past the merge base since the merge (`fd96a8473`, `9ff74285f`, `6f17d1d36`). None touches these six paths. The stale-tree note `dispatch-gates` printed names two CI shard-timing scripts only. ## Risks and costs - On every boot whose seed settles while a platform admin exists, the claim now walks every eligible object, as it already did on over-budget boots. Its predicate is "unowned" (`owner_id` NULL or `usr_system`), not "seeded". On such a boot it therefore also hands over a non-seed row that some system-context writer left unowned. This was already true on the over-budget path and on first-boot promotion, and the card's own repro counts planted nulls as rows that must be claimed. A row someone owns is never matched. - The handler can now run during an earlier plugin's `start()`, before this plugin's `start()`. It touches only the engine (resolved lazily) and the settlement service, writes as `isSystem` through the same predicates, and is best-effort: a failure is logged at `warn` and never breaks the boot. - Rollback: revert the PR. Behaviour returns to first-boot-only claims, and nothing persisted needs undoing. ## Acceptance notes - **Composition order differs between the CLI and the test harness.** `objectstack dev`/`serve` registers `SecurityPlugin` before the app's `AppPlugin`. `@objectstack/verify`'s `bootStack` registers the app first. The kernel keeps registration order among plugins with no edge between them, so an in-budget `app:seeded` fires before any `start()`-time subscriber registered later. This PR makes the claim independent of that by subscribing in `init()`. The other `app:seeded` subscribers (plugin-auth's membership backfill, platform-objects' attestation) subscribe in `start()`, but each has a `kernel:ready` backstop, so no defect was found there. Observation only. carrier: none ("承接者:无"). - **`skills/objectstack-platform/references/plugin-hooks.md`** shows `ctx.hook('app:seeded', …)` without saying that a subscription made in `start()` can miss an in-budget seed from an earlier-registered app. No sentence there is false, so it was not edited (`skills/**` is Tier H). carrier: none. - **`os meta resync` never runs the claim** on an install that already has an admin: it short-circuits on `already_have_admin`. A code comment claimed it did; that comment is corrected here, and nothing else promised it. Observation only. The serial constraint is cleared: PR #21488 merged first, and this branch merged `origin/main` afterwards. The overlapping region in `security-plugin.ts` merged cleanly, and its `metadata:reloaded` block sits above the unchanged heading line of the old start-time block. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 25797a1 commit f9a8eb8

6 files changed

Lines changed: 706 additions & 223 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
The seed-ownership claim now runs whenever a seed settles, on every boot, not only on the boot that promotes the first platform admin.
6+
7+
Clause-②: no
8+
9+
- **Before:** a later boot whose seed replay inserted rows into a database that already had a platform admin left those rows `owner_id` NULL for good. An in-budget seed settles before `kernel:ready`, and the bootstrap that runs there finds the existing admin (`already_have_admin`) and promotes nobody, so neither path reached the claim. A `readScope: 'own'` grant never saw those rows.
10+
- **Now:** when a seed settles (`app:seeded`) before this boot's bootstrap has named a claim target, the handler resolves the target itself: the existing platform admin, by the bootstrap's own `already_have_admin` rule. The claim then hands the replayed rows to that admin. The handler subscribes in `init()`, so a seed that settles before this plugin's `start()` is heard too. That happens on any composition that registers the app first.
11+
- Unchanged: the claim's predicates (`owner_id` NULL or `usr_system`), its object filter and the first-boot promotion path. A row someone else owns is never touched. Under a walled tenancy posture no claim runs, as before.
12+
- Log lines: the claim report reads `handed N seeded record(s) to platform admin USER_ID`, where it used to say `first admin`. Its provisional and failure lines now say when the claim actually runs next: the next seed settle, on this boot or a later one, or the next platform-admin promotion. `os meta resync` is not such a run.

‎content/docs/data-modeling/seed-data.mdx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -422,9 +422,12 @@ whatever identity the load run carries (`config.identity`). During the normal
422422
boot sequence no identity is supplied, so `os.user` resolves to a null
423423
identity and `cel\`os.user.id\`` evaluates to `null` — the record still seeds
424424
successfully, with the field left `null` rather than the load failing. Once
425-
the first human user is promoted to platform admin, a one-time ownership
426-
handoff re-owns every orphaned row (`owner_id` `null`, or the legacy
427-
`usr_system` value from older databases) to that admin.
425+
the first human user is promoted to platform admin, an ownership handoff
426+
re-owns every orphaned row (`owner_id` `null`, or the legacy `usr_system`
427+
value from older databases) to that admin. The handoff is not one-time: it
428+
runs again whenever a seed settles, on that boot and on every later one, so
429+
rows a later boot's seed replay inserts go to the existing platform admin the
430+
same way. A row someone already owns is never touched.
428431

429432
- Because `cel\`os.user.id\`` resolves to `null` before an admin exists, a
430433
**required** (non-nullable) owner-style field must not depend on it —

0 commit comments

Comments
 (0)