Repository navigation
Description
Blocked-by: #11825
Found while implementing #11955 (making successThreshold bind from every status that
records a failure). Outside that card's surface — it is fenced to the success branch's
status gate — so this is filed rather than fixed.
What was measured
packages/core/src/health-monitor.ts at b797b30566, in attemptRestart:
try {
// Call destroy and init to restart
if (plugin.destroy) {
await plugin.destroy();
}
// Note: Full restart would require kernel context
// This is a simplified version - actual implementation would need kernel integration
this.logger.info('Plugin restarted', { plugin: pluginName });
// Reset counters on successful restart
this.failureCounters.set(pluginName, 0);
this.successCounters.set(pluginName, 0);
this.healthStatus.set(pluginName, 'recovering');
} catch (error) { … }The comment above the call says "Call destroy and init to restart". Only destroy() is
called. init() is never called, and nothing else in the file calls it — init appears in
health-monitor.ts exclusively inside that comment. So the sequence a plugin actually gets
is: destroy, then a log line reading Plugin restarted, then status recovering, then
periodic health checks continue to run against the destroyed instance.
Declared contract
packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts:
autoRestart: z.boolean().default(false)
.describe('Automatically restart plugin on health check failure'),content/docs/references/kernel/plugin-lifecycle-advanced.mdx:114 repeats it verbatim.
Neither says the restart is destroy-only, and the operator-visible signals all say the
opposite: the log line asserts Plugin restarted, restartAttempts is incremented as
though a restart occurred, and maxRestartAttempts / restartBackoff schedule further
"restarts" of a plugin that was never brought back up.
Why the severity moved
Before #11955, a restarted plugin sat at recovering and reached healthy on its next
successful check. After #11955 it reaches healthy after successThreshold consecutive
successful checks. Either way the terminal report for a destroyed, never-re-initialised
plugin is healthy — but the second shape is more convincing, because the plugin now has
to pass a declared number of consecutive checks to get there. The default health check when
no checkMethod resolves is { name: 'plugin-loaded', status: 'passed' }, which a
destroyed plugin passes indefinitely.
What is NOT claimed
Whether the right resolution is to implement a real restart, to stop claiming one, or to
retire the surface is not determined here, and it is downstream of #11825 — which records
that nothing reads .health and that the kernel never constructs PluginHealthMonitor at
all. The kernel context this code says it needs is exactly what #11825 is about, so this
should be triaged after that card settles: if the surface is retired, this is moot; if it
is wired up, the missing init() becomes implementable for the first time.
Back-links: #11955 · #11825 · PR #12031.
Generated by Claude Code
Activity
os-warren commentedon Aug 26, 2026
Claimed and dispatched
session_01W6HFzyH98W1YaQXhJUJt6o(seat post [PM seat] domain:server · seat 1 — ⏳ vacant #6367)claude/issue-12032-autorestart-never-reinitsBlocker discharged, verified rather than inherited: #11825 is closed via merged PR #12336, ruling ADR-0049 route 2 — the authorable
AdvancedPluginLifecycleConfigsurface is retired, and the lifecycle classes stay as a kept host-driven library. So the card's own "triage after #11825 settles" condition is answered, and answered in the direction that keeps this live:PluginHealthMonitorremains published and hand-drivable, which makes a destroy-without-init()"restart" that ends athealthya defect in a shipped library rather than a defect in something on its way out.Serial check: lands in
packages/core/src/health-monitor.ts. The two sibling cards in this module family are on different files — #12428 / PR #12571 is onhot-reload.ts, #12397 / PR #12581 is in objectql. No overlap, no hold.Triage's three dispatch notes go to the dev intact, including the one that sets the bar: a loud "destroy-only, will not re-init" posture is acceptable; a silent
healthyafter destroy is not. The remedy fork (implement a real restart through a host-provided re-init hook, versus stop claiming a restart) is the dev's to answer against what a host can provide post-#12336 — and the surrounding precedent is strong and recent: #12340 and #12428 both took "keep the class, stop the lie" in this same library, today.Also carried: re-take the spec/docs citations on the post-#12336 ref before quoting them. That PR rewrote the lifecycle spec and docs surface, so the card's
plugin-lifecycle-advanced.zod.tsdescribe-string andplugin-lifecycle-advanced.mdx:114references may have moved or gone. The library defect stands either way — but a PR quoting a line that no longer exists is a PR nobody can check.Generated by Claude Code
fix(spec,core): PluginHealthMonitor stops claiming a restart it never…
os-warren commentedon Aug 26, 2026
{ "issue": 12032, "status": "done", "branch": "claude/issue-12032-autorestart-never-reinits", "pr": "https://github.com/objectstack-ai/objectstack/pull/12589", "premise_still_valid": true, "clause2": "YES — this changes accept/reject behaviour on a shipped surface, on two doors. It is not merely 'what is logged and what status is recorded'.", "clause2_evidence": "git diff --stat vs merge base ee3595cefd: 15 files, 1257 insertions(+), 260 deletions(-). Door 1 (the parse) — an accept-set NARROWING in packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts (152 +++++++-): '- autoRestart: z.boolean().default(false)' / '+ autoRestart: retiredKey(AUTO_RESTART_RETIRED),' and the same for maxRestartAttempts (z.number().int().min(0).default(3)) and restartBackoff (z.enum(['fixed','linear','exponential']).default('exponential')); three keys that parsed now reject. The ratchets moved with it — packages/spec/authorable-surface/kernel.json (6 +-) gained three '[RETIRED]' markers, packages/spec/authorable-defaults/kernel.json (3 -) lost three defaults. Door 2 (registration) — packages/core/src/health-monitor.ts (250 +++++++------) adds 'function assertNoRetiredKeys(pluginName, config)' which does 'throw healthMonitorRefusal(...)', called as the FIRST statement of registerPlugin; a method that previously always returned now throws an ADR-0112 envelope (code 'VALIDATION_ERROR', status 400) before any state is stored.", "routeChosen": "Route 2 — stop claiming a restart — taken to its coherent end: ADR-0049 REMOVE. autoRestart, maxRestartAttempts and restartBackoff are tombstoned in @objectstack/spec 18 (retiredKey, three RETIRED_KEYS_BY_MAJOR[18] entries + the D3 semantic entry plugin-auto-restart-never-reinitialised); attemptRestart, calculateBackoff and the restartAttempts counter are removed; PluginHealthMonitor no longer calls plugin.destroy() at all. A failing plugin is reported degraded/unhealthy/failed and left running.", "routeReasoning": "Measured, not presumed. (1) ENFORCE unavailable: Plugin.init(ctx) needs a PluginContext, and the only two plugin.init(...) call sites in the tree are the kernel's own boot loops (kernel-base.ts:202, kernel.ts:607), both over the full plugin list with a context that is `private` on ObjectKernel and `protected` on KernelBase — createContext() is protected, no public accessor exists. A host-provided re-init hook would have had nothing to call, so route 1 would have required BUILDING a per-plugin re-init kernel API. Positive control for that scan fired: the same pass resolves five real non-test plugin.destroy() call sites. ADR-0049's staged decision names building enforcement for a feature that does not exist as the wrong default at this milestone ('the real, shippable liability is the false promise, not the missing feature'). (2) EXPERIMENTAL unavailable: a scan of the whole docs/ planning + ADR corpus returned ZERO mentions of plugin auto-restart, against 118 control hits for 'health' and 13 for 'hot reload' in the same corpus; PluginHealthMonitor: 0 hits. ADR-0049 prefers removal when there is no committed roadmap. (3) The two sibling keys leave WITH autoRestart rather than as a tidy-up: with no restart, 'Maximum restart attempts before giving up' and 'Backoff strategy for restart delays' have nothing left to be the vocabulary OF — the same test that took distributedConfig out with the stateStrategy value it was documented as requiring (#12340). Keeping them would have created two fresh declared-but-unenforced keys in a PR retiring one. (4) A pure destroy-only posture was rejected on the same ground plus a stronger one: a half-restart is strictly worse than no action — it turns a degraded plugin into a dead one that the host still owns — and it would have left maxRestartAttempts provably inert. Precedent followed: #12340 (PR #12425) and #12428 (PR #12571), one class over, with the difference named in the PR — those keys had NO reader; this one had a reader that acted, and what it did was not what the key declared.", "summary": "attemptRestart called plugin.destroy() and stopped; `init` appeared in health-monitor.ts only inside the comment claiming both were called. Reproduced on unfixed code at ee3595cefd with successThreshold 3: failed -> recovering (destroyed=1, alive=false) -> recovering -> recovering -> healthy (destroyed=1, alive=false) — the terminal report on a torn-down, never-re-initialised plugin was `healthy`, and #11955 made it more convincing by requiring successThreshold consecutive passes to get there. Fixed by removing the declaration rather than the symptom: three spec keys tombstoned, the pseudo-restart deleted from core, registerPlugin refusing a config that still carries one, and the hand-written lifecycle.mdx paragraph that TAUGHT the false contract corrected. Everything else in the monitor is untouched — #4875's refd-timer guard, #11852's shared counters and #11955's successThreshold gate all still hold; `recovering` is now written only by the success branch.", "tests": "All exit codes captured before any pipe (out=$(cmd 2>&1); rc=$?). At final head ea9a6c90c1: `pnpm --filter @objectstack/core exec vitest run` -> 'Test Files 39 passed (39) / Tests 987 passed (987)'. `pnpm --filter @objectstack/spec exec vitest run` -> 'Test Files 432 passed (432) / Tests 11480 passed (11480)'. `pnpm --filter @objectstack/spec run typecheck` -> rc 0, 'check:test-typecheck: OK — @objectstack/spec's test layer compiles'. `pnpm --filter @objectstack/core build` -> rc 0. `pnpm lint` (eslint . --no-inline-config, WHOLE repo, no narrowing claimed) -> rc 0. Targeted suite: health-monitor.test.ts 23 passed (was 18 on main). GATE UNION DERIVED, NOT RECALLED: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` over the real changeset -> 47 gates; re-derived after the final commit and byte-identical. 46/47 green at ea9a6c90c1. NOT-MEASURED (reported as nothing, never as green): check:dev-prereqs — its scan half refuses on an unbuilt workspace, naming 34 app packages this diff never touches, and prints 'Nothing was measured ... It is NOT a finding'; CI runs only the other half (lint.yml:3304, 'self-test half only, never the scan'), which is green here ('✓ check:dev-prereqs --self-test — every verdict reachable ... (16 cases)'). Three gates first read red for the same unbuilt-prerequisite reason and were CONVERTED into real readings by building the dependency closures rather than reported as failures: check:doc-formula-expressions rc 0; check:doc-security-posture '✅ 26 ObjectSchema.create example(s) in 230 marked block(s) across 237 prose file(s) ... carry an os validate-clean security posture'; check:skill-examples '✅ 260 prose examples type-check across 3 surface(s)'. Control-byte self-scan clean on all 15 changed files, with a firing positive control.", "ablations": [ { "leg": 1, "mutation": "Re-introduce the defect class: `await plugin.destroy()` + healthStatus.set(...,'recovering') on the threshold-reached branch of recordFailedRound. Marker OS_ABLATION_12032_DESTROY.", "rebuild": "NONE, justified by import form not assertion: health-monitor.test.ts imports './health-monitor.js' — a relative SOURCE import inside the same package, resolved by vitest to src/health-monitor.ts, never through the package `exports` map to dist/. (A cross-package ablation of @objectstack/spec from core WOULD have needed a rebuild.)", "mutationProvenOnDisk": "Anchored counts read BEFORE any result: INJECTED_MARKER_COUNT=1, INJECTED_DESTROY_CALL=1, REMOVED_SYNC_SIG_COUNT=0 (the sync `private recordFailedRound(` signature really left), ADDED_ASYNC_SIG_COUNT=1, git diff --numstat = '14 3 packages/core/src/health-monitor.ts'. Three anchors asserted count==1 before mutating, so a zero-hit edit could not have passed as a mutation.", "predictedDirection": "RED", "predictedCount": 5, "observedDirection": "RED", "observedCount": 6, "predictionOutcome": "MISSED by one — recorded, not smoothed over. I wrote off 'requires all three successes to leave `unhealthy`' on the grounds that its failureThreshold:2 needed more failing rounds than the test drives; it drives exactly two ('return-failure' on rounds 1 and 2) before switching to 'pass', so round 2 does reach the threshold. Miscounted the test's own drive, not the mechanism.", "restore": "trap '...' EXIT INT TERM fired ('TRAP_RESTORED'); verified with an empty `git diff` on health-monitor.ts." }, { "leg": 2, "mutation": "Identical mutation, re-run after adding the isolated terminal-state pin. Motivated by leg 1: the richer walk-the-sequence pin tripped an EARLY status assertion first ('expected recovering to be failed'), so the contract itself was not the thing that broke.", "rebuild": "NONE, same relative-source import justification.", "mutationProvenOnDisk": "INJECTED_MARKER_COUNT=1, INJECTED_DESTROY_CALL=1, REMOVED_SYNC_SIG_COUNT=0, git diff --numstat = '14 3 packages/core/src/health-monitor.ts'.", "predictedDirection": "RED", "predictedCount": 7, "observedDirection": "RED", "observedCount": 7, "predictionOutcome": "EXACT. And the predicted assertion identity held: the isolated pin failed on 'the monitor reported `healthy` for a plugin that had been destroyed: expected false to be true' — the terminal state, not the missing call.", "restore": "trap fired; verified with an empty `git diff` on health-monitor.ts." } ], "changedAssertions": "Declared in three places (report, PR body, and inline on the line in each test file). Four #11852 tests and one #11955 test pinned the false behaviour AS the contract — each drove a plugin past failureThreshold then asserted destroyed.count===1 and status 'recovering', i.e. asserted a restart had happened. Rewritten: 'restarts a plugin whose check THROWS...' -> 'leaves a plugin whose check THROWS alone...'; 'restarts a plugin whose check exceeds timeout...' -> 'leaves a plugin that exceeds timeout alone...' (timeout-report half kept verbatim); 'still restarts a plugin whose check RETURNS a failure' -> 'leaves a plugin whose check RETURNS a failure alone'; 'leaves a throwing plugin alone when autoRestart is false' -> replaced by the refusal block (the control went vacuous once the key was gone); 'requires all three successes to leave the `recovering` a restart wrote' -> '...a `recovering` it did not just write' (its premise WAS the restart path; the same property is re-pinned by suspend/resume). Spec side: 'should validate custom health check configuration' carried autoRestart:true in a toEqual fixture — the strongest single pin of the false contract — and 'should validate health check with defaults' asserted all three retired defaults.", "gates": { "green": [ "node scripts/check-adr-0087-registration.mjs", "node scripts/check-changeset-no-major.mjs", "node scripts/check-ci-filter-parity.mjs", "node scripts/check-comment-mask-adoption.mjs", "node scripts/check-cross-package-test-inputs.mjs", "node scripts/check-doc-frontmatter.mjs", "node scripts/check-doc-route-spelling.mjs", "node scripts/check-docs-section-name.mjs", "node scripts/check-empty-changeset.mjs", "node scripts/check-plugin-teardown-shape.mjs", "node scripts/check-section-landing-index.mjs", "node scripts/docs-audit/check-affected-docs.mjs", "node scripts/docs-audit/check-drift-comment.mjs", "node scripts/pm/release-rehearsal-clone.mjs --self-test", "pnpm --filter @objectstack/lint run check:doc-formula-expressions", "pnpm --filter @objectstack/lint run check:doc-security-posture", "pnpm --filter @objectstack/spec run check:authorable-surface", "pnpm --filter @objectstack/spec run check:docs", "pnpm --filter @objectstack/spec run check:empty-state", "pnpm --filter @objectstack/spec run check:generated", "pnpm --filter @objectstack/spec run check:liveness", "pnpm --filter @objectstack/spec run check:skill-examples", "pnpm --filter @objectstack/spec run check:strictness-ledger", "pnpm --filter @objectstack/spec run check:variant-docs", "pnpm check:changeset-gate-self-tests", "pnpm check:cross-package-test-inputs", "pnpm check:doc-anchors", "pnpm check:doc-authoring", "pnpm check:docs-audit-scope", "pnpm check:docs-redirects", "pnpm check:docs-single-h1", "pnpm check:kernel-hook-pairs", "pnpm check:merge-driver", "pnpm check:objectql-double-limit", "pnpm check:objectui-changeset", "pnpm check:page-declaration-shape", "pnpm check:published-files", "pnpm check:published-readme-links", "pnpm check:query-options-erasure", "pnpm check:quick-reference-counts", "pnpm check:react-page-adapter-contract", "pnpm check:role-word", "pnpm check:slot-lookup", "pnpm check:spec-parsed-alias", "pnpm check:test-source-alias", "pnpm check:type-source-resolution", "node scripts/check-dev-prereqs.mjs --self-test (the half CI actually runs)" ], "red": [], "notMeasured": [ "node scripts/check-dev-prereqs.mjs (scan half) — refused on an unbuilt workspace ('The workspace is not built — 1 unmet precondition, not a list of problems', naming 34 app packages outside this diff) and states 'Nothing was measured ... It is NOT a finding'. CI runs only the --self-test half (lint.yml:3304, 'self-test half only, never the scan'), which is green here." ] }, "openQuestions": [], "out_of_scope_findings": [] }Notes for the reviewer
Route, in one line: the fork resolved to stop claiming a restart, and the measurement that decided it is that
ObjectKernel.contextisprivateandKernelBase.createContext()isprotected— so a host-provided re-init hook has nothing to call, and route 1 would have meant building a per-plugin re-init kernel API for a caller that does not exist.The bar: triage set it at "a loud destroy-only, will not re-init posture is acceptable; a silent
healthyafter destroy is not." This clears it from above — there is no destroy at all, because a half-restart turns a degraded plugin into a dead one the host still owns, and because destroy-only would have leftmaxRestartAttemptsprovably inert (a fresh ADR-0049 violation inside a PR retiring one).Citations re-taken, as instructed: the
.zod.tsdescribe-string survived #12336 unchanged;plugin-lifecycle-advanced.mdx:114moved to line 66 and that page is now auto-generated; and the citation the card did not have —content/docs/protocol/kernel/lifecycle.mdx:737— was the hand-written page teaching the false contract, now corrected.CI status at report time:
in_progress— reported at draft-PR time per the dispatch contract, not after CI convergence.Generated by Claude Code
Generated by Claude Code
os-warren commentedon Aug 26, 2026
os-dev-report
(Marker recovery: the previous comment (#issuecomment-5425965035) opened with the HTML-comment form of this marker and the body sanitizer ate it after the write landed, so that comment is invisible to a marker scan. It is re-posted here opening with the literal text instead. No comment-edit tool is available from this seat, so this supersedes it rather than replacing it; the two carry the same report, and the earlier one also holds the longer reviewer notes.)
{ "issue": 12032, "status": "done", "branch": "claude/issue-12032-autorestart-never-reinits", "pr": "https://github.com/objectstack-ai/objectstack/pull/12589", "premise_still_valid": true, "clause2": "YES — accept/reject behaviour on a shipped surface changes, on two doors. Not merely 'what is logged and what status is recorded'.", "clause2_evidence": "git diff --stat vs merge base ee3595cefd: 15 files, 1257 insertions(+), 260 deletions(-). Door 1 (the parse) — accept-set NARROWING in packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts (152 +++++++-): '- autoRestart: z.boolean().default(false)' -> '+ autoRestart: retiredKey(AUTO_RESTART_RETIRED),', same for maxRestartAttempts (was z.number().int().min(0).default(3)) and restartBackoff (was z.enum(['fixed','linear','exponential']).default('exponential')); three keys that parsed now reject. Ratchets moved with it: packages/spec/authorable-surface/kernel.json (6 +-) gained three '[RETIRED]' markers, packages/spec/authorable-defaults/kernel.json (3 -) lost three defaults. Door 2 (registration) — packages/core/src/health-monitor.ts (250 +++++++------) adds 'function assertNoRetiredKeys(pluginName, config)' containing 'throw healthMonitorRefusal(...)', invoked as the FIRST statement of registerPlugin: a method that previously always returned now throws an ADR-0112 envelope (code 'VALIDATION_ERROR', status 400) before any state is stored.", "routeChosen": "Route 2 — stop claiming a restart — taken to its coherent end: ADR-0049 REMOVE. autoRestart, maxRestartAttempts and restartBackoff tombstoned in @objectstack/spec 18 (retiredKey + three RETIRED_KEYS_BY_MAJOR[18] entries + D3 semantic entry plugin-auto-restart-never-reinitialised); attemptRestart, calculateBackoff and the restartAttempts counter removed; PluginHealthMonitor no longer calls plugin.destroy() at all. A failing plugin is reported degraded/unhealthy/failed and left running.", "routeReasoning": "Measured, not presumed. (1) ENFORCE unavailable: Plugin.init(ctx) needs a PluginContext; the only two plugin.init(...) call sites in the tree are the kernel's own boot loops (kernel-base.ts:202, kernel.ts:607), both over the full plugin list, with a context that is private on ObjectKernel and protected on KernelBase — createContext() is protected and no public accessor exists, so a host-provided re-init hook would have had nothing to call and route 1 meant BUILDING a per-plugin re-init kernel API. Positive control fired: the same scan resolves five real non-test plugin.destroy() call sites. ADR-0049's staged decision names building enforcement for a feature that does not exist as the wrong default at this milestone. (2) EXPERIMENTAL unavailable: scan of the whole docs/ planning+ADR corpus returned ZERO mentions of plugin auto-restart against 118 control hits for 'health' and 13 for 'hot reload'; PluginHealthMonitor 0 hits. (3) maxRestartAttempts and restartBackoff leave WITH autoRestart, not as a tidy-up: with no restart they have nothing left to be the vocabulary OF — the #12340 test that took distributedConfig out with the stateStrategy value it required. Keeping them would create two fresh declared-but-unenforced keys inside a PR retiring one. (4) Pure destroy-only was rejected: a half-restart turns a degraded plugin into a dead one the host still owns, and it leaves maxRestartAttempts provably inert.", "summary": "attemptRestart called plugin.destroy() and stopped; `init` appeared in health-monitor.ts only inside the comment claiming both were called. Reproduced on unfixed code at ee3595cefd with successThreshold 3: failed -> recovering (destroyed=1, alive=false) -> recovering -> recovering -> healthy (destroyed=1, alive=false). Fixed by removing the declaration rather than the symptom: three spec keys tombstoned, the pseudo-restart deleted from core, registerPlugin refusing a config that still carries one, and the hand-written lifecycle.mdx paragraph that TAUGHT the false contract corrected. #4875's refd-timer guard, #11852's shared counters and #11955's successThreshold gate all still hold; `recovering` is now written only by the success branch.", "tests": "Exit codes captured before any pipe. At final head ea9a6c90c1: core suite 'Test Files 39 passed (39) / Tests 987 passed (987)'; spec suite 'Test Files 432 passed (432) / Tests 11480 passed (11480)'; `pnpm --filter @objectstack/spec run typecheck` rc 0 ('check:test-typecheck: OK'); `pnpm --filter @objectstack/core build` rc 0; `pnpm lint` (eslint . --no-inline-config, WHOLE repo, no narrowing claimed) rc 0. health-monitor.test.ts 23 passed (18 on main). Gate union DERIVED via `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` -> 47 gates, re-derived byte-identical after the final commit; 46/47 green at ea9a6c90c1. Control-byte self-scan clean on all 15 changed files with a firing positive control.", "ablations": [ {"leg": 1, "mutation": "re-add `await plugin.destroy()` + healthStatus.set(...,'recovering') on the threshold-reached branch of recordFailedRound (marker OS_ABLATION_12032_DESTROY)", "rebuild": "none — health-monitor.test.ts imports './health-monitor.js', a relative SOURCE import inside the same package resolved by vitest to src/, never through `exports` to dist/", "mutationProvenOnDisk": "INJECTED_MARKER_COUNT=1, INJECTED_DESTROY_CALL=1, REMOVED_SYNC_SIG_COUNT=0, ADDED_ASYNC_SIG_COUNT=1, git diff --numstat '14 3 packages/core/src/health-monitor.ts' — read before any result; three anchors asserted count==1 before mutating", "predictedDirection": "RED", "predictedCount": 5, "observedDirection": "RED", "observedCount": 6, "predictionOutcome": "MISSED by one, recorded not smoothed: I wrote off 'requires all three successes to leave `unhealthy`' as needing more failing rounds than the test drives; it drives exactly two before switching to pass, so round 2 does reach failureThreshold:2", "restore": "trap EXIT INT TERM fired; verified with an empty git diff"}, {"leg": 2, "mutation": "identical, re-run after adding the isolated terminal-state pin (leg 1 showed the richer pin tripped an EARLY assertion first)", "rebuild": "none, same justification", "mutationProvenOnDisk": "INJECTED_MARKER_COUNT=1, INJECTED_DESTROY_CALL=1, REMOVED_SYNC_SIG_COUNT=0, git diff --numstat '14 3 packages/core/src/health-monitor.ts'", "predictedDirection": "RED", "predictedCount": 7, "observedDirection": "RED", "observedCount": 7, "predictionOutcome": "EXACT, and the predicted assertion identity held: the isolated pin failed on 'the monitor reported `healthy` for a plugin that had been destroyed: expected false to be true' — the terminal state, not the missing call", "restore": "trap fired; verified with an empty git diff"} ], "changedAssertions": "Declared in report, PR body, and inline on the line in each test file. Four #11852 tests and one #11955 test pinned the false behaviour AS the contract (each asserted destroyed.count===1 and status 'recovering' after crossing failureThreshold). Rewritten: 'restarts a plugin whose check THROWS...' -> 'leaves a plugin whose check THROWS alone...'; 'restarts a plugin whose check exceeds timeout...' -> 'leaves a plugin that exceeds timeout alone...' (timeout-report half kept verbatim); 'still restarts a plugin whose check RETURNS a failure' -> 'leaves ... alone'; 'leaves a throwing plugin alone when autoRestart is false' -> replaced by the refusal block (control went vacuous); 'requires all three successes to leave the `recovering` a restart wrote' -> '...a `recovering` it did not just write' (premise WAS the restart path; re-pinned via suspend/resume). Spec side: 'should validate custom health check configuration' carried autoRestart:true in a toEqual fixture; 'should validate health check with defaults' asserted all three retired defaults.", "gates": { "green": ["node scripts/check-adr-0087-registration.mjs", "node scripts/check-changeset-no-major.mjs", "node scripts/check-ci-filter-parity.mjs", "node scripts/check-comment-mask-adoption.mjs", "node scripts/check-cross-package-test-inputs.mjs", "node scripts/check-doc-frontmatter.mjs", "node scripts/check-doc-route-spelling.mjs", "node scripts/check-docs-section-name.mjs", "node scripts/check-empty-changeset.mjs", "node scripts/check-plugin-teardown-shape.mjs", "node scripts/check-section-landing-index.mjs", "node scripts/docs-audit/check-affected-docs.mjs", "node scripts/docs-audit/check-drift-comment.mjs", "node scripts/pm/release-rehearsal-clone.mjs --self-test", "pnpm --filter @objectstack/lint run check:doc-formula-expressions", "pnpm --filter @objectstack/lint run check:doc-security-posture", "pnpm --filter @objectstack/spec run check:authorable-surface", "pnpm --filter @objectstack/spec run check:docs", "pnpm --filter @objectstack/spec run check:empty-state", "pnpm --filter @objectstack/spec run check:generated", "pnpm --filter @objectstack/spec run check:liveness", "pnpm --filter @objectstack/spec run check:skill-examples", "pnpm --filter @objectstack/spec run check:strictness-ledger", "pnpm --filter @objectstack/spec run check:variant-docs", "pnpm check:changeset-gate-self-tests", "pnpm check:cross-package-test-inputs", "pnpm check:doc-anchors", "pnpm check:doc-authoring", "pnpm check:docs-audit-scope", "pnpm check:docs-redirects", "pnpm check:docs-single-h1", "pnpm check:kernel-hook-pairs", "pnpm check:merge-driver", "pnpm check:objectql-double-limit", "pnpm check:objectui-changeset", "pnpm check:page-declaration-shape", "pnpm check:published-files", "pnpm check:published-readme-links", "pnpm check:query-options-erasure", "pnpm check:quick-reference-counts", "pnpm check:react-page-adapter-contract", "pnpm check:role-word", "pnpm check:slot-lookup", "pnpm check:spec-parsed-alias", "pnpm check:test-source-alias", "pnpm check:type-source-resolution", "node scripts/check-dev-prereqs.mjs --self-test (the half CI actually runs)"], "red": [], "notMeasured": ["node scripts/check-dev-prereqs.mjs (scan half) — refused on an unbuilt workspace ('The workspace is not built — 1 unmet precondition, not a list of problems', naming 34 app packages outside this diff) and prints 'Nothing was measured ... It is NOT a finding'. CI runs only the --self-test half (lint.yml:3304, 'self-test half only, never the scan'), green here."] }, "openQuestions": [], "out_of_scope_findings": [] }CI status at report time:
in_progress— reported at draft-PR time per the dispatch contract, not after CI convergence.Generated by Claude Code
Generated by Claude Code
fix(spec,core): PluginHealthMonitor stops claiming a restart it never…
docs(core): repair both `registerPlugin` examples in PHASE2_IMPLEMENT…
fix(spec,core): ADR-0049 tombstones name the npm release that carries…
test(spec): kernel test titles state each cited decision in words ins…