Skip to content

PluginMetadata.hotReloadable is declared and documented but has zero reads — HotReloadManager.reloadPlugin never consults it #12587

Description

@os-warren

Found while measuring #11982 (the PluginConfigValidator / configSchema measure-first card). Filed unassigned, not fixed — it is a sibling field on the same interface as that card's subject, but outside its completion scope: resolving #11982 would not touch this field.

Measured on origin/main @ 7bd6447f413d36d1f4f0112042de32beefa3e67c.

What is declared

packages/core/src/plugin-loader.ts:58, on PluginMetadata extends Plugin:

/** Whether plugin supports hot reload */
hotReloadable?: boolean;

Documented at packages/core/ADVANCED_FEATURES.md:374 — `hotReloadable?: boolean` - Whether plugin supports hot reload.

What is measured

hotReloadable has exactly 2 occurrences in the whole repo (packages, apps, examples, excluding dist/ and CHANGELOG.md), and both are the declaration and its own doc line:

packages/core/src/plugin-loader.ts:58        <- the declaration
packages/core/ADVANCED_FEATURES.md:374       <- the doc line describing it

Zero reads, and also zero declarations — no plugin in the repo sets it. In particular packages/core/src/hot-reload.ts never mentions it: HotReloadManager.reloadPlugin() (hot-reload.ts:360) gates on this.reloadConfigs.get(pluginName) — a separately registered reload config — and nothing else. A plugin declaring hotReloadable: false is hot-reloaded exactly like one declaring true, which is the stronger form of the claim: the field's own text is an assurance about reload safety.

Controls — an uncontrolled zero is not a reading

Same probe (git grep over the same pathspec, both the name: object-literal form and the name = class-property form), run against sibling optional fields of the same PluginMetadata interface:

Field X: + X = hits Live read
hotReloadable 2 (decl + doc only) none
startupTimeout 22 packages/core/src/kernel.ts:596 — plugin.startupTimeout || this.config.defaultStartupTimeout!
healthCheck 104 live
version = / name = 292 / 2349 (proves the class-property = form is findable at all)

So the probe finds sibling reads on the same interface, and finds class-property assignments generally. The zero is about this field, not about the pattern.

Why it is worth a card rather than a shrug

This is the declared ≠ enforced shape the repo has paid for repeatedly (#4212 — four of five declared plugin lifecycle hooks never invoked; #4914 — loading's ten inert keys, one of which looked like isolation). Two routes, and the choice is a real one, not prejudged here:

  • Enforce it — have reloadPlugin() refuse (or warn on) a plugin whose metadata says hotReloadable === false, making the declaration mean what it says.
  • Retire it under ADR-0049 enforce-or-remove — nothing declares it and nothing reads it, so removal is cheap. Note it is a TypeScript interface field, not a Zod key, so there is no retiredKey() tombstone route and no silent-strip hazard; deletion is visible to consumers as a type error only if someone were setting it, and nobody is.

⚠️ @objectstack/core re-exports this surface publicly (src/index.ts:16, export * from './plugin-loader.js'), so PluginMetadata is a published type and the field is part of a published shape. Deleting a published capability is a maintainer call, same floor as #11982 — this is filed as a finding for triage, not as a licence to remove.

Related: #11982 (same interface, configSchema, currently heading to the decision inbox with its own zero-caller measurement).

Activity

  1. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    Collaborator

    Triage: graded — into the decision inbox (needs-user-decision, keeps domain:engine), type Task. Both routes touch the human floor: retiring the field deletes a published capability (PluginMetadata is re-exported from @objectstack/core), and enforcing it adds new refuse/warn behaviour to reloadPlugin() on a public surface with zero current users. Companion to #11982 (same interface, same zero-caller shape) — recommend ruling the two in one sitting, but each keeps its own card and verdict.

    四棱卡面块(决策待裁)

    选项 A — enforce:让 HotReloadManager.reloadPlugin() 对 hotReloadable === false 的插件拒绝(或警告),使声明兑现。
    选项 B — retire:按 ADR-0049 enforce-or-remove 删除该字段(接口成员 + ADVANCED_FEATURES.md:374 文档行);TS 接口字段,无 zod 墓碑路线、无静默剥除风险,全仓零声明零读者,删除对现存代码零影响。

    推荐:B(retire),四轴同向;与 #11982 同批呈报。(仅为推荐——删除已发布能力恒为人工地板,本座位不代裁。)


    Generated by Claude Code

  2. os-zhuang commented on Aug 27, 2026

    @os-zhuang
    Contributor

    Maintainer ruling recorded — Option B: retire hotReloadable under ADR-0049

    Provenance: maintainer, 2026-08-27, PM chat decision-inbox batch 5 (adjudication session session_01DKWDdUJ2XNRESVVWUvcpnh), verbatim: 「同意」 — accepting recommendation B (all four facets same direction), presented paired with #11982.

    Ruled: remove PluginMetadata.hotReloadable (plugin-loader.ts:58) and its doc line (ADVANCED_FEATURES.md:374). Zero declarations, zero reads, controls fired (sibling startupTimeout read live) — the field's own text is a reload-safety assurance the runtime has never honoured, the exact "declare a capability the runtime does not deliver" shape the platform refuses. ⛔ A (adding refuse/warn behaviour to reloadPlugin() for zero users) declined.

    Dispatch notes: Clause-②: yes — removes a member from a published type (PluginMetadata re-exported from @objectstack/core); contract-review tier. May fold with #11982's retirement into one family dispatch if the five fold gates pass; each card keeps its own Fixes line and its own pin (a declared hotReloadable no longer type-checks — compile-time assertion, proven able to fail).

    State: needs-user-decision → pm:queue.


    Generated by Claude Code

  3. self-assigned this
    on Aug 27, 2026
  4. os-zhuang commented on Aug 27, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 1
    Session: session_01LZbWd2jNV1FErXTPSS4Dry
    Branch: claude/issue-11982-retire-plugin-config-schema
    Worktree: objectstack-issue-11982
    Domain: domain:engine
    File surface: packages/core/src/plugin-loader.ts (the PluginMetadata declaration), the HotReloadManager module and its test (stop on breach; explain in the report)
    Container & model: M family dispatch, mode:subagent, model: claude-fable-5
    Clause-②: yes
    Serial constraints cleared: see the chain head — full serial-constraint check lives on #11982's claim (packages/core/src/security/index.ts overlaps PR #12652, sequenced not blocked).

    ⚠️ Family member, not a chain head. This card is dispatched as the second member of a folded family whose head is #11982; the shared branch is named after the head. ⛔ This card having no branch of its own is by design, not a half-state. The PR carries one Fixes line per member and one commit per member, so this card is independently reviewable and cannot be silently dropped from the batch.

    The ruling — ⛔ not re-adjudicable

    Maintainer, 2026-08-27, decision-inbox batch 5, verbatim, ⛔ untranslated:

    同意

    Ruled B — retire under ADR-0049, adjudicated in the same batch as #11982 and recorded on that card: "Companion #12587 (same interface, hotReloadable) is ruled B in the same batch."

    The defect, as measured by the filing seat

    PluginMetadata.hotReloadable is declared and documented with zero reads: HotReloadManager.reloadPlugin() gates only on its own reloadConfigs map, so a plugin declaring hotReloadable: false is hot-reloaded identically to one declaring true. That is the declared-not-enforced shape — a plugin author is offered a switch that is not wired to anything.

    ⛔ Confirm or refute on the current ref before writing code. The zero-read claim is the entire basis of the ruling; if hotReloadable has acquired a reader since it was measured, stop and report rather than deleting a live gate.

    Control, and it fired: the sibling startupTimeout measures kernel.ts 4 hits / kernel.test.ts 8 — read live. ⛔ startupTimeout, signature and healthCheck are on the exclusion list: they are not in this family and must not be touched.

    🚨 Governed surface — draft only

    The head card's ADR-0025 edit puts this PR on a governed surface (docs/adr/**). ⛔ Never flip ready, never enqueue, never auto-merge. It stays draft for maintainer hand-merge.


    Generated by Claude Code

  5. claude commented on Aug 27, 2026

    @claude
    Contributor

    Dev claim — folded family dispatch, second member (chain head #11982)

    • Session: session_01LZbWd2jNV1FErXTPSS4Dry — dev seat (os-dev subagent, mode:subagent; the PM claim above is the dispatching seat of the same session, so the session ID matches by design — this comment records that a dev ran)
    • Branch: claude/issue-11982-retire-plugin-config-schema (shared family branch, named after the chain head; this card deliberately has no branch of its own)
    • Worktree: objectstack-issue-11982
    • Comments re-read to the last one in the same action as this claim: no competing dev claim from any other session or branch.
    • Executing the recorded ruling (Option B, retire under ADR-0049): remove PluginMetadata.hotReloadable and its ADVANCED_FEATURES.md doc line, as its own commit with its own Fixes line so this member is independently reviewable. Exclusion list honoured: startupTimeout, signature, healthCheck untouched.
    • PR will be draft and must stay draft (the head card's docs/adr/** edit makes the PR a governed-surface diff — maintainer hand-merge).

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions