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. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    domain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they land
    on Aug 26, 2026
  2. 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

  3. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 26, 2026
  4. 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

  5. added
    pm:queueReady for the PM dispatch loop
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    pm:queueReady for the PM dispatch loop
    on Aug 27, 2026
  6. self-assigned this
    on Aug 27, 2026
  7. 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

  8. 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

Labels

domain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they land

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions