Repository navigation
fix(cloud-connection,plugin-security): a hot install fires the package record-change flows and projects its permission sets without a restart - #21488
Conversation
…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
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 17 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 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
|
…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>
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.jsoninto a runningos start, the installed package's record-change flow now fires, and its permission set has itssys_permission_setrow right away. Before this, both needed a restart. The restart path and the--artifactboot path are unchanged, and each reads the same as before.What was measured first (the card's premise holds on
main4c8363f, after PR #21401)This was measured at the public door: a new CLI integration suite spawns
os start, runsos package installagainst it, and probes the result over REST. The runtime boots a host artifact that declaresrequires: ['automation', 'triggers']. An emptyos startcomposes 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.sys_permission_set?name=tasks_app_task_userPATCH status=donesys_job?name=tasks_app_tickmanaged_by: package)os start --artifactcontrol, beforemanaged_by: package,package_id: com.example.tasksapp)Where the boot does this work (measured from the symbols, not the card's line numbers)
service-automation'sAutomationServicePlugin:syncFlowsFromProtocolonkernel:ready, andresyncFlowsFromProtocolonmetadata:reloaded.AppPlugin.starthas no flow step.plugin-security'sSecurityPlugin.runBootstraponkernel:ready, throughseedCatalogPermissionsand thenbootstrapDeclaredPermissions(ql, metadata, …)(ADR-0086 D5). That pass readsql.registry.listItems('permission'). Nothing re-ran it after the boot.kernel:readyand 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 announcesmetadata:reloadedwithchanged: ['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 atwarnwith the restart that repairs it, and never fails the install. The rehydrate does not announce, so the restart path is unchanged.@objectstack/plugin-security. Ametadata:reloadedsubscriber 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, becausetriggerdispatch propagates. The seeder is idempotent and writes nothing when no set changed. As a side effect, the artifact-reload door gets the same projection.packages/runtime(app-artifact-handlers.tsandapp-plugin.tsare untouched), inpackages/spec,service-automationorobjectql. 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 anAppPlugin.startstep that the binder could share. Both arekernel:readysweeps owned by the consumer plugins.bindAppArtifactHandlersis a synchronousql-only function, andAppPlugin.startcalls it beforekernel: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 inpackages/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'shandlernames afunctionsentry, a compiled artifact carries only the lowered string ref, and the callable rides in the siblingobjectstack-runtime.HASH.mjsthat onlyos start --artifactimports (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--artifactcontrol. Each phase pins thesys_permission_setrow and the flow's note. Result ated91d99506: 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 siblingpackage-install-local-handlers.integration.test.tsran 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 onewarnthat names the restart. A context withouttriggersays so.packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts(new, 3 tests). The tests drive the realSecurityPluginhooks. A set registered afterkernel:readygets 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 inscripts/engine-double-contract.pinned.json, as the gate asks.@objectstack/cloud-connection32 files, 406 tests green.@objectstack/plugin-security163 files, 3522 tests green (45 skipped).@objectstack/cli --project unit248 files green. Two published-subpath pins first stopped on PREREQUISITE NOT MET (no CLIdist) and were green afterpnpm --filter @objectstack/cli build. Typecheck is green for all three packages.Ablations (one-shot; each leg mutated through
scripts/ablation-replace.mjs, proven indist/withablation-dist-preflight.mjs, restored and rebuilt)dist/. The unit file read 4 red, with the rehydrate case green.--absentpreflight green, tree clean against HEAD.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackwas re-derived with no paths after the last code commit.--ranreconciliation: 76 derived, 76 run, 0 NOT-MEASURED, each with a recorded exit 0.check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET) and was green after building the 9 unbuilt packages.check:engine-double-contractfirst 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 andcheck:adr-anchorswere re-run on it, all green.pnpm lint(CI-owned) as a proven narrowing ated91d99506. ① ESLint's ownisPathIgnoredreports all 5 changed TS files as linted. The changeset and the JSON ledger are not in any config object. ②eslint --no-inline-config --format jsonover them gives 5 files, 0 errors and 0 warnings, and 1 file, 0 and 0 onf1fefdf6e9. ③eslint.config.mjsenables no type-aware linting: noparserOptions.projectand 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
DELETE /api/v1/marketplace/install-local/com.example.tasksappanswers 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 thesys_permission_setrow stays. The pre-existing path (install, restart, DELETE, restart) leaves the same row. Install-local's DELETE runs noregisterUninstallCleanup(security.package-permissions). That is reported as a finding on the card, not fixed here.DELETE /api/v1/packages/com.example.tasksappanswers 422WRITABLE_PACKAGE_REQUIRED, which is a different door.positionsandcapabilities, and the ADR-0090 audience-binding suggestion for anisDefaultset, are also seeded only by thekernel:readybootstrap. This PR re-runs only the permission-set seeding the card names.os package installcannot add capabilities to a running runtime. A package whose flows needautomationandtriggersinstalls green into a runtime booted without them, and its flows never fire, before or after a restart.metadata:reloadeddescription inpackages/spec/src/contracts/plugin-lifecycle-events.tsstill names only the artifact watcher as its emitter, but it has four now. Carrier: none (spec-seat file).mainmoved 3 commits past the base (4c8363f) during the run. None touchespackages/cloud-connection,plugin-security,runtime,service-automation,objectqlorpackages/cli, so the branch was not merged forward.Generated by Claude Code