Skip to content

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

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21321-install-local-script-actions
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21321-install-local-script-actions

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21321
Clause-②: yes (widening)

An app installed with os package install ./dist/objectstack.json (install-local) now runs its type: 'script' action bodies and its body hooks exactly as the same artifact does under os start --artifact, on install, after a reinstall and after a restart. MCP list_actions now lists a script action only when run_action can run it. Triage's rulings on the card are implemented as written: route A, probe A, and the hook half folded in.

What changed

  • One binder, @objectstack/runtime. The new module packages/runtime/src/app-artifact-handlers.ts exports bindAppArtifactHandlers(ql, bundle, { appId, logger, source }) and appArtifactHandlerOwner(appId), and the package index re-exports both. The function binds an artifact's action bodies through ql.registerAction and its hook bodies and bundle functions through ql.bindHooks, all under the owner app: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 because bindHooksToEngine unregisters only when it is given a non-empty list.
  • AppPlugin.start calls the binder in place of its two inline blocks. The order (after runtime.onEnable), the log lines and the failure handling are the same as before.
  • Install-local, @objectstack/cloud-connection. The plugin calls the binder on POST /api/v1/marketplace/install-local, after manifest.register and syncSchemas and before translations and seeds. It calls it again on the kernel:ready rehydrate 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 a warn that names the consequence. There is no fallback registration path.
  • Probe for list_actions. registeredActionHandlerProbe sits beside executeRegisteredAction in action-execution.ts. It reads the engine's public listRegisteredActions() once per listing and walks the same actionHandlerObjectKeys x resolveActionHandlerKeys order as the run door. list_actions uses it for the script branch: every action invokeBusinessAction sends to the handler registry, meaning neither a declarative update nor a flow. Those two branches keep their own checks. An engine without listRegisteredActions lists no script action. No engine hasAction was added.
  • Nothing changes in packages/objectql, packages/metadata-protocol or packages/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 one beforeInsert body hook that appends stamped to status. The flow is os build, then an empty os start (OS_CLOUD_URL=off), then os package install ./dist/objectstack.json. Probes went over REST and over MCP Streamable HTTP with a minted API key.

phase door before (main f397608) after (this branch)
after install insert, hook 201, status: null 201, status: "stamped"
REST POST /api/v1/actions/tasks_app_task/complete_task 404 RESOURCE_NOT_FOUND 200 {ok:true}, row done
MCP run_action isError: "No handler registered for action 'complete_task' on 'tasks_app_task'" {ok:true, result:{ok:true}}, row done
MCP list_actions lists complete_task (that run_action then refuses) lists complete_task (that run_action runs)
after reinstall all four same as after install same as after install; hook fires once (stamped)
after restart (same home) all four 404, "No handler registered", status: null; restart log re-synced runtime-authored actions {"registered":0,...} 200, ok, stamped; restart log [MarketplaceInstallLocal] Bound declarative actions {"appId":"com.example.tasksapp","actionCount":2}
control os start --artifact all four 200, ok, stamped 200, ok, stamped

Pins (each measured red on unfixed code first)

  • packages/cli/test/package-install-local-handlers.integration.test.ts (integration tier, spawn) runs the real os start and os package install through the tsx source entry, across install, reinstall, restart and the --artifact control. Each phase checks four things: the hook fires once, the REST action runs, MCP run_action runs and list_actions lists the action, and a declared AI-exposed ghost_task (a target nothing registers) is not listed while run_action refuses 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.ts uses 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.ts covers 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.ts runs the binder on a real ObjectQL engine with the QuickJS sandbox: executeAction runs the body, triggerHooks runs 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 and git status --porcelain empty; dist legs rebuilt and checked with scripts/ablation-dist-preflight.mjs both ways)

leg mutation red
A delete the install-route bind call cc pin 3 of 4 (install, both reinstall cases); spawn pin 6 (after-install and after-reinstall hook, REST, MCP); restart and control stay green
B delete the rehydrate bind call cc pin: rehydrate; spawn pin 3 (after-restart hook, REST, MCP)
C1 ql.removeActionsByPackage(owner) to void 0 binder pin and cc pin (via runtime dist): dropped action still bound
C2 ql.unregisterHooksByPackage(owner) to void 0 binder pin and cc pin (via dist): dropped hook still fires
D packageId: owner to packageId: undefined binder pin 2 (re-bind runs the hook twice; dropped hook); cc pin 3 (via dist); spawn pin 1 (after-reinstall hook fires twice)
E remove the probe condition in list_actions probe pin 3 (the defect and its two siblings); spawn pin 4 (ghost_task listed in every phase)
F AppPlugin call replaced by void bindAppArtifactHandlers; spawn pin 3 (control hook, REST, MCP)

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/runtime vitest run --project local (2 shards): 305 files, 4341 passed, 11 skipped.
  • @objectstack/cloud-connection full: 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.
  • typecheck of runtime, cloud-connection and cli: exit 0, with each check:test-typecheck OK.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 66 gate commands. All 66 were run with their exit codes recorded and all exited 0. --ran reconciled: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN.
  • pnpm lint (the full eslint . --no-inline-config): exit 0.

Acceptance notes

  • AppPlugin now clears app:APPID before it binds. A first boot is unchanged. If two AppPlugins 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.
  • Seven existing install-local suites (bundle, conflict, id-gate, list-posture, offline-degradation, posture-gate, storage-dir) gained a module-top import '@objectstack/runtime'. An install or rehydrate now reaches the runtime's lazy import, and its first load inside a 5000ms it timed out posture-gate and id-gate (the clocked-window rule in scripts/check-test-source-alias.mjs). The suite now pays that load during collection. No baseline duration was measured.
  • The two list_actions engine fixtures in http-dispatcher.test.ts gained listRegisteredActions(), listing the keys their executeAction already answers.
  • The spawn pin runs in development through the tsx entry and signs in as the dev-admin seed. Using the production bin/run.js entry would add the file to check:cli-test-child-env's pinned roster of six built-entry spawners, which needs an edit to that gate.
  • The spawn pin does not cover a reinstall that drops an action or hook. The binder and cc unit pins cover it (legs C1 and C2).
  • The [AppPlugin|MarketplaceInstallLocal] Bound declarative actions count is registrations, not distinct handlers. The same action collected from actions[] and objects[].actions[] counts 2 for one handler. This is unchanged and noted only.
  • Hot install via os package install leaves record-change flows unbound and the package's permission sets unprojected until a restart, and says nothing #21322 (flows and permission sets on hot install) is not addressed here. It can reuse bindAppArtifactHandlers.

Generated by Claude Code

claude added 5 commits October 2, 2026 09:22
…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>
@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/runtime, touching 12 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/runtime/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/client-sdk.mdx (via appId (symbol, a field of interface AppArtifactHandlerBindingOptions))
  • content/docs/api/error-catalog.mdx (via /api/v1/actions (route, a path literal in a comment in start))
  • content/docs/automation/flows.mdx (via /api/v1/actions (route, a path literal in a comment in start))
  • content/docs/concepts/metadata-lifecycle.mdx (via /api/v1/actions (route, a path literal in a comment in start))
  • content/docs/deployment/cli.mdx (via MarketplaceInstallLocalPlugin (symbol, a top-level class))
  • content/docs/permissions/authorization.mdx (via buildMcpBridge (symbol, a top-level function))
  • content/docs/ui/actions.mdx (via /api/v1/actions (route, a path literal in a comment in start))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via /api/v1/actions (route, a path literal in a comment in start))
  • content/docs/releases/v17/17-1.mdx (via /api/v1/actions (route, a path literal in a comment in start))
  • content/docs/releases/v17/17-5.mdx (via /api/v1/actions (route, a path literal in a comment in start))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/runtime/src/index.ts) — pages documenting those are invisible to this run
  • 6 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 — 28 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 ecb6ca0258176466767588a6805363387c5777a6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 92517b0c59685edf283df5aacac52f41c0d031c2 — the merge of head 2e4e1ff4f9f4f6303daee6d24f91f20bca2bc7dc into base ecb6ca0258176466767588a6805363387c5777a6, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2e4e1ff4f9f4f6303daee6d24f91f20bca2bc7dc
Local-runs: none

Head fetched into refs/review/pr-21401; it did not move during the review. Merge base with origin/main: db0cf2231bf54e75fc6fa5d0d412576d755e0fcb (origin/main at ecb6ca0258, 11 commits ahead of the base). Net diff: 19 files, +1277/-104, equal to the PR's file list. No governed surface in the file list; head repo is the base repo; 1,381 changed lines.

① Derived judgments

(a) Route A — right.

  • packages/runtime/src/app-artifact-handlers.ts is the one binder: bindAppArtifactHandlers(ql, bundle, { appId, logger, source }) and appArtifactHandlerOwner(appId) returning app:APPID; the index re-exports both plus the two types. At the head the app: owner literal has exactly one writer (git grep over packages/*/src, non-test: appArtifactHandlerOwner only).
  • AppPlugin.start (app-plugin.ts L1107) calls it once, after runtime.onEnable, in place of the two inline blocks (base L1090-1143 hooks, L1145-1193 actions). Compared block by block: same collectors (collectBundleHooks, collectBundleFunctionEntries, collectBundleActions), same hookBodyRunnerFactory / actionBodyRunnerFactory over new QuickJSScriptRunner(), same owner literal, same GLOBAL_ACTION_OBJECT_KEY fallback for object-less actions, the same five log lines under [AppPlugin] (the caller passes source: 'AppPlugin', and the tag defaults to it), same warn/error posture. Two differences, both unobservable on a first bind: (1) the teardown removeActionsByPackage(owner) then unregisterHooksByPackage(owner) — engine.ts L4564-4570 and L3832-3848 filter on entry.package === owner / e.packageId !== packageId and log only when something was removed, so an empty owner set is a silent no-op; (2) the action runner is now constructed only when actions.length is positive and registerAction exists, where before it was constructed unconditionally — QuickJSScriptRunner's constructor (quickjs-runner.ts L78-92) only resolves timeout options; the WASM module loads lazily on first run. So os start --artifact is byte-for-byte unchanged apart from the no-op teardown. The three sandbox imports stay live in app-plugin.ts (L538, L578 default-runner installs); the GLOBAL_ACTION_OBJECT_KEY import was dropped with its last use; the binder has no module-level evaluation, so the app-plugin / binder import cycle is safe under ESM and the CJS build (check:dual-build-cjs-loads is in the passing Lint & Repo Gates).
  • Install-local: bindArtifactHandlers (marketplace-install-local-plugin.ts L1422) is called at install step 4c (L952: after manifest.register, which 422s an inline manifest that fails to register, and after syncSchemas; before applySideEffects) and in rehydrate (L316: after register and syncSchemas inside the same try, so a register failure logs at error and skips the bind). appId is manifestId = ManifestSchema.shape.id.safeParse(manifest.id).data (L816-825) on install and entry.manifestId on rehydrate — the same string AppPlugin derives as sys.id || sys.name with sys = bundle.manifest || bundle (L679-680), so one artifact owns one key on either path. collectBundleActions maps objectName to object (app-plugin.ts L2188-2192), so the artifact's top-level actions[] copy and the objects[].actions[] copy land on the one Map key.
  • No second registration path remains: the only other registerAction callers at the head are ObjectQLPlugin.resyncAuthoredActions (owner metadata-service, Studio-authored rows, skipping artifact-shipped actions through isArtifactShippedAction) and user code — neither is install-only. No boot path binds twice: AppPlugin once; the install route once per request; rehydrate once per ledger entry. A reinstall tears the owner's set down then rebinds — one handler per key (the engine's registerAction overwrites by key regardless; the teardown's value is dropping keys the new version removed), and bindHooksToEngine (hook-binder.ts L109-170) registers functions before its empty-list return and unregisters by packageId only on a non-empty list, so the binder's explicit hook teardown is what makes a hook-less reinstall drop the old hooks; on a boot with hooks the owner's unregister runs twice (binder, then bindHooksToEngine), idempotently.

(b) Lazy load with a warn — right: an acceptable version-skew guard, not the forbidden degradation.

  • @objectstack/runtime is a declared workspace:* dependency of @objectstack/cloud-connection at the head; runtime does not depend back on cloud-connection; both sit in the single 69-package fixed changeset group, so published versions move together and the skew can only come from a broken install or a suite that mocks the runtime (seven existing cloud-connection suites vi.mock('@objectstack/runtime', …) without the export and take the warn path — the case the plugin comment names). The lazy import() is the plugin's existing pattern (three prior sites: L449, L478, L558); the IObjectQLEngine type import was already there (L89).
  • On the skew path it binds nothing, logs once per install or rehydrate naming the consequence and the upgrade remedy, and keeps no fallback registration; probe A keeps list_actions from advertising the unbound action and REST answers 404. Under the degradation rule this is a functional degradation (the system is visibly smaller), so warn is the right level. Residual, not a finding: the install response and the CLI's success line do not carry the warn; only the server log does.

(c) Probe A — right; the key walk matches the run door exactly.

  • registeredActionHandlerProbe (action-execution.ts L2544-2561) snapshots ql.listRegisteredActions() into a Set keyed obj:key and answers by walking actionHandlerObjectKeys(objectName) x candidates. executeRegisteredAction (L2505-2521) walks the identical two loops calling ql.executeAction(obj, key) and rotates only on isActionNotRegisteredError.
  • Engine side (engine.ts): the Map key is objectName:actionName (L4519-4523); listRegisteredActions (L4547-4560) splits each key at its first colon — object names are snake_case and never carry one, so each row re-joins to the exact Map key; executeAction (L4528-4534) throws exactly the message the predicate matches on a Map miss. Hence probe true if and only if the run door dispatches, for the same (objectName, candidates).
  • Same inputs: listActions (mcp.ts L686-739) takes objectName from collectActionDeclarations, the one source resolveActionByName reads for run_action; candidates = resolveActionHandlerKeys(action) with no fallback key, identical to invokeBusinessAction L2305; the engine is deps.getObjectQL(context, envId), the run door's own call. A getObjectQL rejection hides every script action (.catch(() => undefined)) where the run door would throw — fail-closed on both.
  • Branch order: the gate applies to !isDeclarativeUpdateAction(action) && action.type !== 'flow', exactly the population invokeBusinessAction sends to the registry (declarative update L2185, flow L2228, else L2305). An undefined type is script on both sides. Nothing the door refuses for "no handler" is advertised, and nothing runnable through the registry is hidden; the pre-existing isHeadlessInvokableAction and ai.exposed / permission gates stay in the same positions.
  • Untouched branches, and right: the declarative update (bound to nothing by construction — the platform performs the write) and flow (dispatched by the automation service; isHeadlessInvokableAction already requires a target and a live automation service). No engine hasAction; no packages/objectql file in the diff. The pin pairs listing and run door on one Map per case: the defect, name-bound, target-bound versus a stray key, the object-less global key, the flow control, and a non-enumerating engine.

(d) Hook half — right. packageId: owner is the same app:APPID literal the AppPlugin block wrote at base, so no existing hook changes owner on the AppPlugin path. An installed body hook fires once (the spawn pin's appending hook reads stamped, never stampedstamped, across install, reinstall, restart and the --artifact control; the binder pin on a real ObjectQL L84-91). A reinstall that drops a hook unbinds it (binder pin L93-103; cc pin L234-245) — which bindHooksToEngine alone would not do.

(e) Pins and the out-of-surface test edits — right; no assertion weakened.

  • Spawn pin (packages/cli/test/package-install-local-handlers.integration.test.ts): the tsx entry comes from the pre-existing, untouched helpers/serve-process.ts (CLI = bin/run-dev.js); scripts/check-cli-test-child-env.mjs's own header (L229-232) names spawning bin/run-dev.js as "the repair for a test that wants source, not a violation". The dev-admin sign-in is the development seed the helper documents. Tier: the file is *.integration.test.ts, not *.e2e or *.live, so it is in the queue population that ci.yml's Test Core runs under OS_TEST_TIERS=queue, and by its spawn signals in the integration project — it ran in this head's Test Core. Each phase reads four real doors; no assertion is a bare status.
  • Seven install-local suites: each gains only import '@objectstack/runtime'; at module top (the clocked-window rule, check-test-source-alias.mjs L206-241); no assertion touched.
  • http-dispatcher.test.ts: the two listRegisteredActions enumerations list exactly the keys their executeAction doubles already answer (fixture 1 L4302-4310: completeTask, issueLicense; fixture 2 L4587-4592: todo_task:archive_task, global:nightly_cleanup, completeTask), with todo_task the only non-system object in each — the listing assertions keep their original population.
  • The cc pin uses the real runtime dist through exports and a recording engine that models bindHooksToEngine's non-empty rule; the binder pin runs on a real ObjectQL with the QuickJS sandbox, including a "never touches another owner" case that covers an owner-less imperative registration (package undefined).

(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 listRegisteredActions gets no script actions listed"; "Declarative update actions and flow actions are listed as before"; "Nothing changes in packages/objectql, packages/metadata-protocol or packages/spec" (file list); "loaded lazily, like the plugin's other runtime helpers" (three prior sites); "bindHooksToEngine unregisters only when it is given a non-empty list" (hook-binder.ts L145-155); the acceptance notes on the double count, the shared-app-id case and the fixtures. The before/after table and the seven ablation legs are the dev's local measurements, consistent with the step-1 report 5946762655, not re-run here. One wording nit in a code comment, not in the changeset or PR body: app-artifact-handlers.ts L62-64 says the re-sync "reads the same owner" — isArtifactShippedAction (objectql/plugin.ts L2320-2335) reads the registry's artifact items, not the owner key; the effect described (resync skips installed actions) holds because manifest.register populates the registry on both paths.

② Semver level

Right. @objectstack/runtime: minor — two new value exports and two new type exports on the package index; Clause-②: yes (widening) is the correct declaration (a new public function, nothing removed or narrowed; hiding a list_actions row that could never run is a machine-readable-surface fix, not a contract narrowing). @objectstack/cloud-connection: patch — a fix with no new public surface. Both are in the one fixed group and ship at one version; the per-package grade is still what the CHANGELOG records, and it is right. Check Changeset is green on the head.

③ Boundary flags

Implemented-by: claude/issue-21321-install-local-script-actions
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 11:58
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 11:58
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 1d0600b Oct 2, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21321-install-local-script-actions branch October 2, 2026 12:19
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
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/xl tests tooling

Projects

None yet

2 participants