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
|
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