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

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