Skip to content

fix(cloud-connection,plugin-security): a hot install fires the package record-change flows and projects its permission sets without a restart - #21488

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21322-hot-install-binds-boot-steps
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21322-hot-install-binds-boot-steps

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #21322. This PR covers the flows and permission-set half. The jobs half is left open for a decision (see "Jobs" below), so merging this must not close the card.
Clause-②: no

After os package install ./dist/objectstack.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 #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 os package install (install-local) drops an app's script action bodies: REST 404 and MCP run_action "No handler registered" while list_actions advertises the action #21321 handler binder, the ledger write and the seed, the route 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 os package install (install-local) drops an app's script action bodies: REST 404 and MCP run_action "No handler registered" while list_actions advertises the action #21321 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

claude added 4 commits October 2, 2026 21:34
…ta:reloaded; security re-projects declared permission sets on it

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…and the reload-time permission-set projection; changeset

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…ble in the pinned ledger

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
@github-actions github-actions Bot added the size/l label Oct 2, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 2, 2026
@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/plugin-security, touching 3 documentable anchor(s).

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

  • content/docs/deployment/cli.mdx (via MarketplaceInstallLocalPlugin (symbol, a top-level class))
What this run could not see
  • 1 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 — 17 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 0b8239111fe2195a8d6d120568367ee547d96003 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3d6c4ef9acb6405772bc0e507ce55fa0d56f55c7 — the merge of head f1fefdf6e99762dff88664231db10af748a66ed7 into base 0b8239111fe2195a8d6d120568367ee547d96003, 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 3d6c4ef9acb6405772bc0e507ce55fa0d56f55c7 && git checkout 3d6c4ef9acb6405772bc0e507ce55fa0d56f55c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0b8239111fe2195a8d6d120568367ee547d96003 f1fefdf6e99762dff88664231db10af748a66ed7 && git checkout -B drift-repro 0b8239111fe2195a8d6d120568367ee547d96003 && git merge --no-ff f1fefdf6e99762dff88664231db10af748a66ed7

node scripts/docs-audit/affected-docs.mjs --json 0b8239111fe2195a8d6d120568367ee547d96003

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 23:21
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 23:21
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit ab52182 Oct 2, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21322-hot-install-binds-boot-steps branch October 2, 2026 23:54
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants