Repository navigation
fix(runtime,cloud-connection): an install-local package binds its script-action bodies and body hooks; list_actions lists only what run_action can run - #21401
Conversation
…tion bodies and body hooks, called by AppPlugin and by install-local; list_actions reads the handler registry Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…e; install-local suites pay the runtime load at module top Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…s the handler registry), cloud-connection patch Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…stall-local-script-actions Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
… signs in as the dev admin, and attributes every exchange to the child Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 28 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 92517b0c59685edf283df5aacac52f41c0d031c2 && git checkout 92517b0c59685edf283df5aacac52f41c0d031c2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ecb6ca0258176466767588a6805363387c5777a6 2e4e1ff4f9f4f6303daee6d24f91f20bca2bc7dc && git checkout -B drift-repro ecb6ca0258176466767588a6805363387c5777a6 && git merge --no-ff 2e4e1ff4f9f4f6303daee6d24f91f20bca2bc7dc
node scripts/docs-audit/affected-docs.mjs --json ecb6ca0258176466767588a6805363387c5777a6
|
Contract reviewServed-tier: Head fetched into ① Derived judgments(a) Route A — right.
(b) Lazy load with a warn — right: an acceptable version-skew guard, not the forbidden degradation.
(c) Probe A — right; the key walk matches the run door exactly.
(d) Hook half — right. (e) Pins and the out-of-surface test edits — right; no assertion weakened.
(f) Changeset and PR prose — each factual sentence true at the head: the export names; "same log lines and the same results for a boot artifact"; "An engine without ② Semver levelRight. ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…e record-change flows and projects its permission sets without a restart (objectstack-ai#21488) Part of objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21321
Clause-②: yes (widening)
An app installed with
os package install ./dist/objectstack.json(install-local) now runs itstype: 'script'action bodies and its body hooks exactly as the same artifact does underos start --artifact, on install, after a reinstall and after a restart. MCPlist_actionsnow lists a script action only whenrun_actioncan run it. Triage's rulings on the card are implemented as written: route A, probe A, and the hook half folded in.What changed
@objectstack/runtime. The new modulepackages/runtime/src/app-artifact-handlers.tsexportsbindAppArtifactHandlers(ql, bundle, { appId, logger, source })andappArtifactHandlerOwner(appId), and the package index re-exports both. The function binds an artifact's action bodies throughql.registerActionand its hook bodies and bundle functions throughql.bindHooks, all under the ownerapp:APPID. It first removes the action handlers and hooks that owner bound before. On a first bind this does nothing. On a reinstall it keeps one handler per action and one binding per hook, and it unbinds an action or hook the new version dropped. The hook removal is explicit becausebindHooksToEngineunregisters only when it is given a non-empty list.AppPlugin.startcalls the binder in place of its two inline blocks. The order (afterruntime.onEnable), the log lines and the failure handling are the same as before.@objectstack/cloud-connection. The plugin calls the binder onPOST /api/v1/marketplace/install-local, aftermanifest.registerandsyncSchemasand before translations and seeds. It calls it again on thekernel:readyrehydrate of each ledger entry, with appId set to the manifest id. The runtime is loaded lazily, like the plugin's other runtime helpers. A runtime without the export binds nothing and logs awarnthat names the consequence. There is no fallback registration path.list_actions.registeredActionHandlerProbesits besideexecuteRegisteredActioninaction-execution.ts. It reads the engine's publiclistRegisteredActions()once per listing and walks the sameactionHandlerObjectKeysxresolveActionHandlerKeysorder as the run door.list_actionsuses it for the script branch: every actioninvokeBusinessActionsends to the handler registry, meaning neither a declarative update nor a flow. Those two branches keep their own checks. An engine withoutlistRegisteredActionslists no script action. No enginehasActionwas added.packages/objectql,packages/metadata-protocolorpackages/spec.Measured with the real CLI, before and after
The app has one object, one script action with an inline body and
ai.exposed, and onebeforeInsertbody hook that appendsstampedtostatus. The flow isos build, then an emptyos start(OS_CLOUD_URL=off), thenos package install ./dist/objectstack.json. Probes went over REST and over MCP Streamable HTTP with a minted API key.status: nullstatus: "stamped"POST /api/v1/actions/tasks_app_task/complete_taskRESOURCE_NOT_FOUND{ok:true}, rowdonerun_action{ok:true, result:{ok:true}}, rowdonelist_actionscomplete_task(that run_action then refuses)complete_task(that run_action runs)stamped)status: null; restart logre-synced runtime-authored actions {"registered":0,...}stamped; restart log[MarketplaceInstallLocal] Bound declarative actions {"appId":"com.example.tasksapp","actionCount":2}os start --artifactstampedstampedPins (each measured red on unfixed code first)
packages/cli/test/package-install-local-handlers.integration.test.ts(integration tier, spawn) runs the realos startandos package installthrough the tsx source entry, across install, reinstall, restart and the--artifactcontrol. Each phase checks four things: the hook fires once, the REST action runs, MCPrun_actionruns andlist_actionslists the action, and a declared AI-exposedghost_task(atargetnothing registers) is not listed whilerun_actionrefuses it. Red on main: 13 failed, 4 passed (the control's three rows and harness health).packages/cloud-connection/src/marketplace-install-local-artifact-handlers.test.tsuses the real runtime binder and a recording engine. It covers install, rehydrate, a reinstall leaving exactly one handler and binding, and a reinstall of a version without the action and hook unbinding both. Red on main: 4 of 4.packages/runtime/src/mcp-list-actions-handler-probe.test.tscovers listing against run_action on one engine double: an unbound body action, a target-bound key, the object-less key, the flow control, and an engine that cannot list its handlers. Red on main: 3 failed, 3 passed (the controls).packages/runtime/src/app-artifact-handlers.test.tsruns the binder on a realObjectQLengine with the QuickJS sandbox:executeActionruns the body,triggerHooksruns the hook, re-binding keeps exactly one of each, a dropped action or hook is unbound, and other owners are left alone.Ablations (fix committed at 2e4e1ff; every leg through
scripts/ablation-replace.mjs, restore proven blob == HEAD andgit status --porcelainempty; dist legs rebuilt and checked withscripts/ablation-dist-preflight.mjsboth ways)ql.removeActionsByPackage(owner)tovoid 0ql.unregisterHooksByPackage(owner)tovoid 0packageId: ownertopackageId: undefinedlist_actionsghost_tasklisted in every phase)void bindAppArtifactHandlers;A first run of leg F deleted the call outright. That left the import unused, so the runtime DTS step failed with TS6133 after the JS had already been emitted. It was re-run with the type-clean replacement in the table, and that run is the one quoted.
Tests and gates (at 2e4e1ff; main merged at db0cf22)
@objectstack/runtimevitest run --project local(2 shards): 305 files, 4341 passed, 11 skipped.@objectstack/cloud-connectionfull: 31 files, 401 passed.@objectstack/cli--project unit(3 shards): 246 files, 3489 passed. Integration project: only the new file was run locally (17 passed). The rest of the integration project is left to CI.typecheckof runtime, cloud-connection and cli: exit 0, with eachcheck:test-typecheckOK.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 66 gate commands. All 66 were run with their exit codes recorded and all exited 0.--ranreconciled: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN.pnpm lint(the fulleslint . --no-inline-config): exit 0.Acceptance notes
AppPluginnow clearsapp:APPIDbefore it binds. A first boot is unchanged. If twoAppPlugins on one engine share an app id, the later one's set now replaces the earlier one's actions as well; before, it replaced only the hooks.bundle,conflict,id-gate,list-posture,offline-degradation,posture-gate,storage-dir) gained a module-topimport '@objectstack/runtime'. An install or rehydrate now reaches the runtime's lazy import, and its first load inside a 5000msittimed outposture-gateandid-gate(the clocked-window rule inscripts/check-test-source-alias.mjs). The suite now pays that load during collection. No baseline duration was measured.list_actionsengine fixtures inhttp-dispatcher.test.tsgainedlistRegisteredActions(), listing the keys theirexecuteActionalready answers.bin/run.jsentry would add the file tocheck:cli-test-child-env's pinned roster of six built-entry spawners, which needs an edit to that gate.[AppPlugin|MarketplaceInstallLocal] Bound declarative actionscount is registrations, not distinct handlers. The same action collected fromactions[]andobjects[].actions[]counts 2 for one handler. This is unchanged and noted only.bindAppArtifactHandlers.Generated by Claude Code