Skip to content

fix(cli): a config-booted app keeps its module code — action handlers reach the engine again (#4095) - #4129

Merged
os-zhuang merged 5 commits into
mainfrom
claude/plugin-boot-logs-visibility-12kkwh
Jul 30, 2026
Merged

fix(cli): a config-booted app keeps its module code — action handlers reach the engine again (#4095)#4129
os-zhuang merged 5 commits into
mainfrom
claude/plugin-boot-logs-visibility-12kkwh

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes #4095.

The loss

os serve <config> calls createStandaloneStack(), which reads dist/objectstack.json and returns a ready-made AppPlugin for the app. That satisfied serve's "does the host already wrap itself?" guard, so the new AppPlugin(config) built from the loaded module — the only one carrying the module's onEnable — was skipped. A JSON artifact cannot hold a function, so the app booted with all of its metadata and none of its code.

Two probes nail it. First, what createStandaloneStack() actually returns once the artifact resolves:

PLUGINS: 4
 - com.objectstack.runtime.default-datasource   isApp=false
 - com.objectstack.metadata                     isApp=false
 - com.objectstack.engine.objectql              isApp=false
 - plugin.app.com.example.todo   ctor=AppPlugin  isApp=true
   bundle.onEnable = undefined      ← the hook that registers the handlers
   bundle.actions  = 8              ← all eight declarations

Second, serve at the branch that discards it:

[PROBE] hasAppPluginAlready=true  configHasMetadata=true  config.onEnable=function

The hook survives the entire config chain — mod.default || mod, the named-export preservation, the docs graft, both mergeBootConfig calls — and is then dropped by the guard. Exactly the observed split: metadata survives, code does not.

examples/app-todo is correctly authored — it declares target: 'completeTask', registers todo_task:completeTask, and exports onEnable. The ADR-0110 D5 inventory had been naming all eight actions since #4012 made boot warnings visible, and it was right.

#4110 has since landed on this same seam from the other side, and its own comment corroborates the diagnosis — "the artifact path never hit it only because createStandaloneStack appends ITS AppPlugin after the engine". It fixed the ordering; the code loss is untouched by it (see "Re-verified against the new base" below).

The fix

Serve grafts the module's executable members onto the app bundle already registered, instead of dropping them along with the wrap.

  • Only what AppPlugin executes travelsonEnable and the functions map that string-named hook/job handlers resolve against. onDisable is deliberately excluded: it is declared in packages/spec but grep finds no kernel, runtime or service that ever calls it, so grafting it would wire a hook nothing runs.
  • The artifact stays the metadata source of truth. Neither side is a superset — the artifact carries compile-time enrichment the config never has (ADR-0046 packaged docs, which serve already grafts the other way), so this is a graft, not a swap. Code only; never metadata.
  • Targeting is by manifest.id, so a host composing several AppPlugins can never have one app's handlers attached to another. With no id to match it falls back to the single app bundle present, and refuses rather than guess when there are several.
  • A bundle's own value always wins, so a host that wrapped itself on purpose is untouched.
  • Orphaned code is now loud. A config whose hooks find no bundle gets a boot warning naming the consequence ("they 404 at dispatch") instead of vanishing — that silent drop is what hid this for so long.

isAppPluginLike is shared between the guard and the graft, so the decision to skip the wrap and the compensation for skipping it cannot drift apart.

Verification — the user-visible thing actually works

Live HTTP against a real examples/app-todo boot:

before after
POST /api/v1/actions/todo_task/complete_task 404 RESOURCE_NOT_FOUND — Action 'complete_task' on object 'todo_task' not found {"success":true}
POST …/export_csv same 404 {"success":true,"data":"subject,status,priority,…\nLearn ObjectStack,completed,…"} — real seeded rows
[action-governance] boot warning 1, naming all 8 0

The intermediate state was informative too: with an empty body the handler returned 400 VALIDATION_ERROR — Update requires an ID, i.e. the failure moved from the router into the handler's own logic.

Tests

  • 14 unit cases on the graft, weighted toward the refusals — id mismatch, ambiguity, never-overwrite, metadata-must-not-travel, onDisable-must-not-travel, and the degenerate inputs a boot can hand it.
  • 1 end-to-end case boots a real stack through bin/run-dev.js with a fixture whose action is executable only if onEnable ran, and asserts the inventory reports nothing. It also asserts the boot reached its banner, so "no warning" cannot pass vacuously. It uses the shared test/helpers/serve-process.ts harness fix(cli,core,metadata,runtime): the platform boots with no application — os serve no longer needs a compiled artifact (#4085) #4110 extracted rather than carrying a second copy.

Its os compile step is retained, with its justification inverted: it was originally a workaround for config-boot being unable to start without an artifact (#4085, now fixed), and it is kept because the artifact is what makes createStandaloneStack() contribute the artifact-derived AppPlugin this regression is about. Without it, serve wraps the config directly, onEnable runs for the trivial reason, and the test would pass while exercising nothing.

Re-verified against the new base

main moved by 12 commits mid-review (#4110, #4133, #4117, #4120, #4114, #4136 …), including two that touch this exact area, so the whole thing was re-checked rather than assumed:

I earlier carried a two-line fix here for the wildcard guard that was failing on mainthat is now dropped: #4133 fixed the same stale MOUNTS entry independently and better (it records why the storage twin disappeared), so the merge takes main's version and my commit contributes nothing. Nothing in this PR touches scripts/ any more.

Relationship to #4085 — checked, and they were not the same bug

I verified this before #4085 was fixed: os serve <config> could not boot without dist/objectstack.json, and that reproduced unchanged with this fix applied — a separate defect on the other side of the same fork. #4110 has since fixed it, from the ordering angle, which confirms the split. The verdict and the lead I left are on #4085.

… reach the engine again (#4095)

`os serve <config>` calls `createStandaloneStack()`, which reads
`dist/objectstack.json` and returns a ready-made `AppPlugin` for the app. That
satisfied serve's "does the host already wrap itself with an AppPlugin?" guard,
so the `new AppPlugin(config)` built from the LOADED MODULE — the only one
carrying the module's `onEnable` — was skipped. A JSON artifact cannot hold a
function, so the app booted with all of its metadata and none of its code.

On examples/app-todo that meant eight declared `script` actions, zero registered
handlers, and every button answering `404 Action 'complete_task' on object
'todo_task' not found`. The example is correctly authored — it declares
`target: 'completeTask'`, registers `todo_task:completeTask`, and exports
`onEnable`; serve carried that hook intact all the way to the branch that
discarded it. The ADR-0110 D5 inventory had been naming all eight since #4012
made boot warnings visible, and it was right.

Serve now grafts the module's executable members onto the app bundle already
registered instead of dropping them with the wrap:

- Only members AppPlugin actually executes travel — `onEnable` and the
  `functions` map that string-named hook/job handlers resolve against.
  `onDisable` is deliberately excluded: it is declared in packages/spec but no
  kernel, runtime or service ever calls it, so grafting it would wire a hook
  nothing runs.
- The artifact stays the metadata source of truth. Neither side is a superset —
  it carries compile-time enrichment the config never has (ADR-0046 packaged
  docs, which serve already grafts the other way) — so this moves code only.
- Targeting is by `manifest.id`, so a host composing several AppPlugins can
  never have one app's handlers attached to another. With no id to match it
  falls back to the single app bundle present, and refuses when there are
  several.
- A bundle's own value always wins, so a host that wrapped itself on purpose is
  untouched.
- Code that finds no bundle to land on is now reported with a boot warning
  naming the consequence instead of vanishing. That silent drop is what hid this.

`isAppPluginLike` is shared, so the guard that decides to skip the wrap and the
graft that compensates for skipping it cannot disagree about what an app plugin
is.

Verified end to end on examples/app-todo: POST /api/v1/actions/todo_task/
complete_task went from 404 RESOURCE_NOT_FOUND to {"success":true}, export_csv
returns real CSV, and the [action-governance] warning naming all eight is gone.
14 unit cases pin the graft and the cases where it must refuse; one end-to-end
case boots a real stack through bin/run-dev.js. Both fail against the pre-fix
command. CLI suite 86 files / 892 tests green; eslint src clean.

`os serve <config>` still cannot boot without dist/objectstack.json (#4085) —
verified to be a separate defect on the other side of the same fork, reproducing
unchanged with this fix applied.

Closes #4095

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HrRNgrWaRtggzmrHpbomyh
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 30, 2026 11:30am

Request Review

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Jul 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli.

18 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via packages/cli)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli)
  • content/docs/automation/hook-bodies.mdx (via packages/cli)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/cli)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli)
  • content/docs/plugins/packages.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli)
  • content/docs/releases/v16.mdx (via @objectstack/cli)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Copy link
Copy Markdown
Contributor Author

Blocked on a red main, not on this diff. The ESLint check is failing, but the failing step inside that job is not ESLint — it is the new wildcard fall-through guard:

✗ wildcard fall-through guard (#4116)

  packages/adapters/hono/src/index.ts:all `${prefix}/storage/*`
    DECLARED but not found by the scan. Moved, renamed or deleted? Update MOUNTS.

Verified on pristine origin/main with this branch nowhere in the picture — same single failure:

git worktree add /tmp/main-check origin/main
cd /tmp/main-check && node scripts/check-wildcard-fallthrough.mjs
#   ✗ wildcard fall-through guard (#4116)  →  1 problem(s)

Cause — a merge-order collision between two landed PRs

'packages/adapters/hono/src/index.ts:all `${prefix}/storage/*`': {
  ratchet: '#4117 — terminal, same shape as #4088; adapter has no in-repo consumer',
},

Each was green on its own base; together the declaration is stale. Since the guard runs on every PR, this currently blocks all of them, not just this one.

Why I have not fixed it here

The remedy is the two-line deletion the guard's own message asks for, but it is in another change's guard and there is a real choice behind it — drop the stale MOUNTS entry, or have the guard tolerate a declared-then-deleted mount rather than fail. That belongs to whoever owns #4116/#4117, so I am not making that call inside an unrelated CLI fix. Happy to push the one-line MOUNTS deletion if that is the wanted shape — just say so.

This branch has main merged in as of 6a381db, and everything under this PR's own scope is green against it: 87 files / 906 tests in @objectstack/cli, eslint src clean, and the end-to-end proof re-verified (complete_task{"success":true}).


Generated by Claude Code

claude added 3 commits July 30, 2026 11:02
…ails on main

`scripts/check-wildcard-fallthrough.mjs` declared the hono adapter's
`${prefix}/storage/*` mount, which no longer exists:

    ✗ wildcard fall-through guard (#4116)
      packages/adapters/hono/src/index.ts:all `${prefix}/storage/*`
        DECLARED but not found by the scan. Moved, renamed or deleted? Update MOUNTS.

#4112 removed that mount outright — the adapter now carries a "deliberately NOT
mounted (#4087)" note in its place — because the wildcard claimed the whole
`/storage` subtree and answered its own 404 for every path `service-storage`
registers under it. That is the #4088 failure actually happening, so removal was
the fix rather than a ratchet.

#4112 and #4122 (which added this scan) were each green on their own base and
landed in that order, leaving the entry declaring a deleted mount. Reproduced on
pristine origin/main, so it is not specific to any branch — and since the guard
runs on every PR, it blocks all of them:

    git worktree add /tmp/main-check origin/main
    cd /tmp/main-check && node scripts/check-wildcard-fallthrough.mjs   # 1 problem(s)

Removes the stale entry, which is what the guard's own message asks for. The
sibling `${prefix}/auth/*` entry stays: that mount is still there
(packages/adapters/hono/src/index.ts:279), so its ratchet is still live. The
comment above them said "Both are the #4088 shape" and is now singular, with the
storage mount's removal recorded so the next reader does not have to reconstruct
why one of a documented pair disappeared.

Verified: `pnpm check:wildcard-fallthrough` self-test 15 cases, then
6 yielding / 1 ratcheted / 5 exempt across 12 namespace-claiming mounts. The two
steps that follow it in the same CI job and never got to run — check:release-notes,
check:node-version — pass too.

Refs #4116, #4117, #4112

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HrRNgrWaRtggzmrHpbomyh
…gs-visibility-12kkwh

# Conflicts:
#	scripts/check-wildcard-fallthrough.mjs
…oduced

#4110 extracted the spawn-the-real-`os serve` harness this test had inlined into
`test/helpers/serve-process.ts`; use it instead of carrying a second copy.

The `os compile` step stays, but its justification inverts. It was there because
config-boot could not start at all without `dist/objectstack.json` (#4085) — now
fixed — and it is kept for a positive reason: the artifact is what makes
`createStandaloneStack()` contribute the artifact-derived AppPlugin whose missing
code this regression is about. Without it serve wraps the config directly,
`onEnable` runs for the trivial reason, and the test would pass while exercising
nothing. Comment says so, so nobody removes it as leftover #4085 scaffolding.

Re-verified against merged main: with `packages/cli/src/commands/serve.ts` taken
from origin/main the case still fails ("the action's handler went unregistered"),
so #4095 is live on the new base and this remains a real guard. Full CLI suite
88 files / 909 tests; eslint src clean.

Refs #4095

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HrRNgrWaRtggzmrHpbomyh
@os-zhuang
os-zhuang marked this pull request as ready for review July 30, 2026 12:40
@os-zhuang
os-zhuang merged commit 49c9a1a into main Jul 30, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/plugin-boot-logs-visibility-12kkwh branch July 30, 2026 12:40
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.

Config-booted apps lose their onEnable hook — every script action handler goes unregistered and 404s at dispatch (examples/app-todo: all 8)

2 participants