Skip to content

fix(cloud-connection,plugin-security): a hot install fires the package record-change flows and projects its permission sets without a restart - #21488

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21322-hot-install-binds-boot-steps
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21322-hot-install-binds-boot-steps

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #21322. This PR covers the flows and permission-set half. The jobs half is left open for a decision (see "Jobs" below), so merging this must not close the card.
Clause-②: no

After os package install ./dist/objectstack.json into a running os start, the installed package's record-change flow now fires, and its permission set has its sys_permission_set row right away. Before this, both needed a restart. The restart path and the --artifact boot path are unchanged, and each reads the same as before.

What was measured first (the card's premise holds on main 4c8363f, after PR #21401)

This was measured at the public door: a new CLI integration suite spawns os start, runs os package install against it, and probes the result over REST. The runtime boots a host artifact that declares requires: ['automation', 'triggers']. An empty os start composes neither capability, so on an empty kernel no flow fires at all, whether hot-installed or restarted. The package reaches the runtime only through the install.

phase sys_permission_set?name=tasks_app_task_user flow note after PATCH status=done sys_job?name=tasks_app_tick
hot install, before 0 rows 0 rows 0 rows
restart on the same home, before 1 row (managed_by: package) 1 row 0 rows
os start --artifact control, before 1 row 1 row 1 row (active)
hot install, after 1 row (managed_by: package, package_id: com.example.tasksapp) 1 row 0 rows
restart, after 1 row 1 row 0 rows
control, after 1 row 1 row 1 row

Where the boot does this work (measured from the symbols, not the card's line numbers)

  • Flows. Binding is done by service-automation's AutomationServicePlugin: syncFlowsFromProtocol on kernel:ready, and resyncFlowsFromProtocol on metadata:reloaded. AppPlugin.start has no flow step.
  • Permission-set projection. This is done by plugin-security's SecurityPlugin.runBootstrap on kernel:ready, through seedCatalogPermissions and then bootstrapDeclaredPermissions(ql, metadata, …) (ADR-0086 D5). That pass reads ql.registry.listItems('permission'). Nothing re-ran it after the boot.
  • Why a restart worked. The install-local rehydrate runs inside kernel:ready and is registered before both sweeps, so they read the rehydrated package. A hot install registers the package after both sweeps have already run.

What changed

  • @objectstack/cloud-connection, the install route. As its last step, after register, schema sync, the os package install (install-local) drops an app's script action bodies: REST 404 and MCP run_action "No handler registered" while list_actions advertises the action #21321 handler binder, the ledger write and the seed, the route announces metadata:reloaded with changed: ['app/MANIFEST_ID']. This is the platform's one post-boot re-sync signal. A Studio package publish (publish-drafts), a per-item publish and an artifact reload already announce it. It runs after the seed because that is where the boot runs these sweeps: a record-change flow bound before the seed would fire on every seeded row. A subscriber failure is logged at warn with the restart that repairs it, and never fails the install. The rehydrate does not announce, so the restart path is unchanged.
  • @objectstack/plugin-security. A metadata:reloaded subscriber re-runs the same declared-permission seeding the boot runs. It uses the same function, the same organization passes (catalogSeedPasses) and the same provenance rules. It runs only once the boot's own pass has finished (bootstrapRanOnce), so the platform defaults keep their insert-once shape. It never throws, because trigger dispatch propagates. The seeder is idempotent and writes nothing when no set changed. As a side effect, the artifact-reload door gets the same projection.
  • Nothing changed in packages/runtime (app-artifact-handlers.ts and app-plugin.ts are untouched), in packages/spec, service-automation or objectql. The install response and the CLI output keep their fields and text.

The landing point differs from the claim's file surface, and why. The claim expected packages/runtime/src/app-artifact-handlers.ts, and triage said flows and permission-set projection would "extend that one binder". The measurement shows that at boot, neither flows nor permission-set projection is an AppPlugin.start step that the binder could share. Both are kernel:ready sweeps owned by the consumer plugins. bindAppArtifactHandlers is a synchronous ql-only function, and AppPlugin.start calls it before kernel:ready. Putting flow binding or projection into the binder would have been exactly the second path the ruling forbids. So the hot install re-runs the consumers' own sweeps, and the one edit outside this lane is the producer side in packages/plugins/plugin-security. That edit is a cross-lane path, named here for the seat to declare.

Jobs: measured, not folded in (needs a decision)

An installed package's defineStack({ jobs }) are never scheduled by install-local, on a hot install or after a restart (table above). The control schedules them. That is not a missing registration step. A job's handler names a functions entry, a compiled artifact carries only the lowered string ref, and the callable rides in the sibling objectstack-runtime.HASH.mjs that only os start --artifact imports (mergeRuntimeModule). An inline install sends the JSON alone, so no step can resolve a handler. The ruling's exception arm (the install response and the CLI name what did not bind) would widen the public response and CLI surface. The hazard note says to stop before writing that, so it is not in this PR. The options are in the report on the card.

Tests

  • packages/cli/test/package-install-local-boot-steps.integration.test.ts (integration tier, new). It has three phases: hot install, restart on the same home, and the --artifact control. Each phase pins the sys_permission_set row and the flow's note. Result at ed91d99506: 7 of 7 green. The os package install (install-local) drops an app's script action bodies: REST 404 and MCP run_action "No handler registered" while list_actions advertises the action #21321 sibling package-install-local-handlers.integration.test.ts ran in the same run, 17 of 17 green. The new announce does not double-bind the installed package's hooks or actions.
  • packages/cloud-connection/src/marketplace-install-local-hot-resync.test.ts (new, 5 tests). The install announces once, naming the app, after register and persist. A reinstall announces again. The rehydrate announces nothing. A throwing subscriber leaves the install at 200 with one warn that names the restart. A context without trigger says so.
  • packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts (new, 3 tests). The tests drive the real SecurityPlugin hooks. A set registered after kernel:ready gets its row, with package provenance, on the reload. A second reload adds no row. A reload before the boot pass writes nothing. Its engine double is recorded in scripts/engine-double-contract.pinned.json, as the gate asks.
  • Full suites: @objectstack/cloud-connection 32 files, 406 tests green. @objectstack/plugin-security 163 files, 3522 tests green (45 skipped). @objectstack/cli --project unit 248 files green. Two published-subpath pins first stopped on PREREQUISITE NOT MET (no CLI dist) and were green after pnpm --filter @objectstack/cli build. Typecheck is green for all three packages.

Ablations (one-shot; each leg mutated through scripts/ablation-replace.mjs, proven in dist/ with ablation-dist-preflight.mjs, restored and rebuilt)

  • Leg A: the install-route announce replaced by a marker. The pin read: install phase, permission-set row red and flow red; restart and control green (2 failed, 5 passed). The leg's DTS step failed on TS6133 for the now-unused private method, but the JS bundles carried the marker, and preflight proved it in dist/. The unit file read 4 red, with the rehydrate case green.
  • Leg B: the security subscriber renamed to a non-event. The pin read: only the install-phase permission-set row red; the install-phase flow stayed green (1 failed, 6 passed). The two halves are independent. The unit file read 2 red, with the before-boot control green.
  • Both restore legs: rebuilt, --absent preflight green, tree clean against HEAD.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack was re-derived with no paths after the last code commit. --ran reconciliation: 76 derived, 76 run, 0 NOT-MEASURED, each with a recorded exit 0. check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET) and was green after building the 9 unbuilt packages. check:engine-double-contract first exited 1 until the new test's double was recorded. The last commit (f1fefdf6e9) only adds an ADR-0086 D5 anchor to one comment. The comment-reading gates and check:adr-anchors were re-run on it, all green.

pnpm lint (CI-owned) as a proven narrowing at ed91d99506. ① ESLint's own isPathIgnored reports all 5 changed TS files as linted. The changeset and the JSON ledger are not in any config object. ② eslint --no-inline-config --format json over them gives 5 files, 0 errors and 0 warnings, and 1 file, 0 and 0 on f1fefdf6e9. ③ eslint.config.mjs enables no type-aware linting: no parserOptions.project and no typed rules, as its own header states. It has no cross-file import rules either, so this diff cannot move a verdict on any untouched file.

Acceptance notes

  • Uninstall symmetry, measured because this change makes it reachable without a restart. After a hot install, DELETE /api/v1/marketplace/install-local/com.example.tasksapp answers 200. The flow still fires and the set's row stays, which matches the route's documented "remains loaded until the next restart". After a restart the package's object answers 404, but the sys_permission_set row stays. The pre-existing path (install, restart, DELETE, restart) leaves the same row. Install-local's DELETE runs no registerUninstallCleanup (security.package-permissions). That is reported as a finding on the card, not fixed here. DELETE /api/v1/packages/com.example.tasksapp answers 422 WRITABLE_PACKAGE_REQUIRED, which is a different door.
  • Same family, not measured. A hot-installed package's declared positions and capabilities, and the ADR-0090 audience-binding suggestion for an isDefault set, are also seeded only by the kernel:ready bootstrap. This PR re-runs only the permission-set seeding the card names.
  • Composition. os package install cannot add capabilities to a running runtime. A package whose flows need automation and triggers installs green into a runtime booted without them, and its flows never fire, before or after a restart.
  • Docs drift. The metadata:reloaded description in packages/spec/src/contracts/plugin-lifecycle-events.ts still names only the artifact watcher as its emitter, but it has four now. Carrier: none (spec-seat file).
  • main moved 3 commits past the base (4c8363f) during the run. None touches packages/cloud-connection, plugin-security, runtime, service-automation, objectql or packages/cli, so the branch was not merged forward.

Generated by Claude Code

claude added 4 commits October 2, 2026 21:34
…ta:reloaded; security re-projects declared permission sets on it

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…and the reload-time permission-set projection; changeset

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…ble in the pinned ledger

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
@github-actions github-actions Bot added the size/l label Oct 2, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cloud-connection, @objectstack/plugin-security, touching 3 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via MarketplaceInstallLocalPlugin (symbol, a top-level class))
What this run could not see
  • 1 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 — 17 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 0b8239111fe2195a8d6d120568367ee547d96003 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3d6c4ef9acb6405772bc0e507ce55fa0d56f55c7 — the merge of head f1fefdf6e99762dff88664231db10af748a66ed7 into base 0b8239111fe2195a8d6d120568367ee547d96003, 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 3d6c4ef9acb6405772bc0e507ce55fa0d56f55c7 && git checkout 3d6c4ef9acb6405772bc0e507ce55fa0d56f55c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0b8239111fe2195a8d6d120568367ee547d96003 f1fefdf6e99762dff88664231db10af748a66ed7 && git checkout -B drift-repro 0b8239111fe2195a8d6d120568367ee547d96003 && git merge --no-ff f1fefdf6e99762dff88664231db10af748a66ed7

node scripts/docs-audit/affected-docs.mjs --json 0b8239111fe2195a8d6d120568367ee547d96003

⚠️ 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 0b8239111fe2195a8d6d120568367ee547d96003 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 23:21
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 23:21
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit ab52182 Oct 2, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21322-hot-install-binds-boot-steps branch October 2, 2026 23:54
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ttles, on every boot (objectstack-ai#21503)

Fixes objectstack-ai#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 objectstack-ai#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 objectstack-ai#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>
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants