You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
guard: fail any Plugin implementation that declares stop() (or shutdown()/close()) with no destroy() — the kernel only ever calls destroy() #10619
Class-closing half of #10371, split out by lane (the six repairs are the services-lane card; this guard is devx gate work). The guard was already ruled FOR by the domain:devx PM on the #9371 thread — this card is the workpiece, not a re-ask.
Why a guard and not six fixes
Plugin (packages/core/src/types.ts:170) declares init(), start?(ctx), destroy?() — and NOT stop(). The trap is the asymmetry: start?() IS on the interface and fires, so a start/stop pair reads symmetric in review while only one half is wired. This survived review in two packages and was only ever found by merge-queue evictions (#9371: two green PRs evicted). Fix six plugins and the seventh arrives spelled shutdown() or close(); the guard makes the class unreachable.
Red condition: class implements a teardown-shaped method (stop/shutdown/close — exact roster is the dev's first design decision, document it in the gate header) and does not implement destroy().
No hard dependency on the six repairs landing first, but the guard will red on all six until they do — either land after the services-lane repairs (#10371), or land with the six known instances baselined and the baseline burned down by that card. Dev picks; say which in the PR.
Refs: #10371 (repairs, same class) · #9371 / PR #10375 (root cause + ruling) · #10374 (upstream amplifier).
Triage: pm:queue, domain:devx, Task — gate addition with a named population and a ruled mandate.
Premise re-verified on origin/main = 78ac958552: Plugin (packages/core/src/types.ts)
declares init(), start?(ctx) and destroy?(): Promise<void> | void — and no stop().
The asymmetry the card names is real: start?()is on the interface and fires, so a start/stop pair reads symmetric in review while only one half is wired.
The guard was already ruled for by the domain:devx PM on the #9371 thread, so this card is
the workpiece, not a re-ask.
⚠️The landing hazard is the whole question here, and it is the same shape as #10658's. The
six known instances live in the services lane (#10371). Once the guard exists it reds on all
six until those repairs land, which would redden this PR's own CI. The card names both routes —
land after #10371, or land with the six baselined. The dev must check whether #10371 has
landed and then choose deliberately, with a ⛔ SHRINK-ONLY baseline if it has not. ⛔ Repairing
the six here is not an option: that is another lane's work and it would make this PR
unreviewable against its card.
Two constraints carried from the card: the positive control must be the real fixture
(MessagingServicePlugin at main before PR #10375), not a synthetic one; and the
delegating-alias shape (stop() calling destroy() or vice versa — the #9371 repair) must stay green. The exact teardown-name roster (stop / shutdown / close) is the dev's first
design decision and belongs in the gate header.
{
"issue": 10619,
"status": "done",
"branch": "claude/issue-10619-teardown-shape-guard",
"pr": "https://github.com/objectstack-ai/objectstack/pull/10774",
"premise_still_valid": true,
"summary": "Added scripts/check-plugin-teardown-shape.mjs: a Plugin implementation that declares a teardown-shaped method (stop / shutdown / close / dispose) and no destroy() fails, because destroy() is the only teardown hook ObjectKernel.performShutdown() and LiteKernel.destroy() call. Wired into lint.yml's already-required 'Lint & Repo Gates' job as a node scripts/... step (root package.json is inside the #9465 fence), self-test leg first. LANDING HAZARD: #10371 has NOT landed - verified by reading the code at origin/main 78ac958552, not the card - so the guard lands with the pre-existing instances in a shrink-only KNOWN_TEARDOWN_UNREACHED list, each entry naming #10371, and the failure text REFUSES new members outright rather than marking them (check:ratchet-remedy-authority classifies it 'refused', its fourth such instance; its CONTROL corpus gained the required declaration entry - the one bounded edit outside the new file). The baseline is ELEVEN, not six: re-deriving the population rather than adopting #10371's list found five instances that card does not name - MetadataPlugin / AppPlugin / ExternalValidationPlugin spell the alias as an arrow PROPERTY (a method-only scan misses them) and EmailServicePlugin / WebhookOutboxPlugin spell it dispose(), i.e. the 'seventh arrives spelled differently' case is already present. ExternalValidationPlugin owns setInterval timers that shutdown never clears. Filed as #10772 (sub-issue of #10371), not repaired here.",
"tests": "All run at final HEAD b97bab513d, working tree clean, exit codes captured before any pipe (cmd > file 2>&1; EXIT=$?); every verdict below is the gate's own printed line, not a bare $?.\n\nNEW GATE. `node scripts/check-plugin-teardown-shape.mjs --self-test` EXIT=0 -> 'check-plugin-teardown-shape self-test: 47 cases pass (real pre-#10375 fixture reds, the repaired file and both delegating-alias directions stay green, every roster name reds, every excluded name stays green, and all five refusals are paired with a tree that still returns a verdict).' `node scripts/check-plugin-teardown-shape.mjs` EXIT=0 -> 'check:plugin-teardown-shape: 54 Plugin implementation(s) across 4371 source(s) under packages/**; every teardown-shaped method (stop / shutdown / close / dispose) sits beside a real destroy() (11 known-unreached, SHRINK-ONLY, repair tracked on #10371).'\n\nPOSITIVE CONTROL is the real fixture, per the card: MessagingServicePlugin extracted with `git show` at 621a487607881c66b2899b7e3477115229a156b4 (PR #10375's squash-merge first parent) - async stop(), no destroy() - reds with exactly one finding. Pinned to a COMMIT, not a checked-in copy, so the fixture cannot be edited into passing; an unreachable revision refuses and prints the deepen command instead of skipping. NEGATIVE CONTROLS: the same file after the #9371 repair (stop() delegating to this.destroy()) stays green; both delegating directions pinned synthetically; destroy()-with-no-alias green; ConnectorMcpPlugin's real `private close?: () => Promise<void>` handle shape green with AND without a destroy().\n\nREFUSALS, each paired with a readable tree that still returns a verdict so 'refuses unconditionally' cannot satisfy the battery: missing population root (paired tree still REDS), zero sources (paired tree returns green), zero Plugin implementations, an unreadable source (a dangling symlink named p.ts - EISDIR/chmod would not hold for root), and an unparseable source asserted OUT OF PROCESS. Deviation from the brief's literal wording, deliberate: an unparseable source exits EXIT_UNPARSEABLE (3) from scripts/ts-parse.mjs, not 1 - routing every parse through that module is what check:parse-guard enforces and it ends the process itself. The self-test asserts non-zero AND that the file is named.\n\nABLATION - prediction stated before running: blinding the red condition (`hasKernelHook: declared.has(KERNEL_HOOK)` -> `true`) reddens 10 of 47 cases. Observed: EXACTLY 10 of 47, and the named cases matched the enumeration one for one (real pre-#10375 fixture, all 4 roster names, the arrow-property spelling, the structural-Plugin case, the test-source case, the paired tree that must still red, and the live-tree ratchet agreement); the live gate also went red with 11 stale entries. NO BUILD IS INVOLVED - the gate is a plain .mjs run directly by node, resolving nothing through any package's dist/, so there is no rebuild leg to state and no dist marker to preflight. MUTATION CONFIRMED ON DISK by anchored grep counts, not by an editor exit code: before `original-anchor=1 injected=0`, after `original-anchor=0 injected=1`. RESTORE LEG confirmed the same way: `original-anchor=1 injected=0`, `git status --porcelain` empty, and the self-test back to 47/47 EXIT=0.\n\nDISPATCH-GATES NAMEABILITY (the #10705 trap). `node scripts/pm/dispatch-gates.mjs packages/plugins/plugin-reports/src/reports-plugin.ts` EXIT=0 prints under 'Local gates for this card': '- node scripts/check-plugin-teardown-shape.mjs [lint.yml] matched via packages/plugins/plugin-reports/src/reports-plugin.ts => gate source \\'packages/**\\''. MATCHED, not undetermined and not silent - the population is declared as the subtree spelling packages/**, never the bare word hintCovers refuses.\n\nGATE UNION re-derived with `node scripts/pm/dispatch-gates.mjs` (NO paths - it took the change set off the merge base itself: 3 paths vs merge base 78ac95855). All 14 legs green at b97bab513d: check-plugin-teardown-shape --self-test (47 cases) | check-plugin-teardown-shape | check:entry-guard ('126 scripts/ file(s) ... 10 known-unsafe' - ledger gained no member) | check:parse-guard ('125 scripts/ file(s) - every TypeScript parse goes through ts-parse.mjs') | check:ratchet-remedy-authority ('112 scripts swept ... 4 turn it down outright ... Control corpus: 20 hand-classified scripts, set-equality audited both ways') + its --self-test | check:required-contexts | check:aggregator-roster ('roster == needs: in both directions') | check:workflow-status-functions | check:cross-package-test-inputs | check:node-version | check:shard-attestation | check:type-check-coverage | check:nul-bytes ('scanned 6235 text file(s) ... no raw ASCII control bytes'), plus a manual `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` over the three edited files - no hits.\n\nDECLARED NARROWING: check:type-check-debt (check-type-check-coverage.mjs --re-measure) was NOT run. Attempted under scripts/pm/os-verify-lock.sh (waited 458s, acquired, ran); it REFUSED with '55 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk' - it needs the full built closure. This diff adds no package source and changes no tsconfig, so the frozen raw-error counts cannot move, and the leg of that gate which actually reads the lint.yml edited here (check-type-check-coverage.mjs, no flag) is green above. CI runs the re-measure on the built closure regardless.\n\nNO CHANGESET - a new gate publishes nothing. skip-changeset applied additively via POST /issues/10774/labels and READ BACK after the bots ran: ['ci/cd', 'size/l', 'skip-changeset']. PR body read back after creation and byte-identical to what was sent except a trimmed trailing newline; the session-URL signature footer survived. CI on PR #10774 is in_progress at report time - per the dispatch contract the report lands at draft-PR time and CI convergence is the PM's read.",
"open_questions": [],
"out_of_scope_findings": [
"filed as #10772: five more Plugin implementations release teardown from a hook the kernel never calls - MetadataPlugin, AppPlugin, ExternalValidationPlugin (arrow-property `stop`), EmailServicePlugin, WebhookOutboxPlugin (`dispose`) - the #10371 enumeration is short by five; linked as a sub-issue of #10371, unassigned, labels domain:services + bug, no pm:queue. ExternalValidationPlugin owns setInterval drift-check timers that kernel shutdown never clears, which is the #9371 mechanism verbatim."
]
}
Activity
claude commentedon Aug 21, 2026
Claim — PM dispatch (devx seat)
c970724d-303c-5614-9d20-a3f92205cfadclaude/issue-10619-teardown-shape-guardPremise re-verified on
origin/main=78ac958552:Plugin(packages/core/src/types.ts)declares
init(),start?(ctx)anddestroy?(): Promise<void> | void— and nostop().The asymmetry the card names is real:
start?()is on the interface and fires, so astart/stoppair reads symmetric in review while only one half is wired.The guard was already ruled for by the
domain:devxPM on the #9371 thread, so this card isthe workpiece, not a re-ask.
six known instances live in the services lane (#10371). Once the guard exists it reds on all
six until those repairs land, which would redden this PR's own CI. The card names both routes —
land after #10371, or land with the six baselined. The dev must check whether #10371 has
landed and then choose deliberately, with a ⛔ SHRINK-ONLY baseline if it has not. ⛔ Repairing
the six here is not an option: that is another lane's work and it would make this PR
unreviewable against its card.
Two constraints carried from the card: the positive control must be the real fixture
(
MessagingServicePluginat main before PR #10375), not a synthetic one; and thedelegating-alias shape (
stop()callingdestroy()or vice versa — the #9371 repair) must staygreen. The exact teardown-name roster (
stop/shutdown/close) is the dev's firstdesign decision and belongs in the gate header.
Generated by Claude Code
claude commentedon Aug 21, 2026
{ "issue": 10619, "status": "done", "branch": "claude/issue-10619-teardown-shape-guard", "pr": "https://github.com/objectstack-ai/objectstack/pull/10774", "premise_still_valid": true, "summary": "Added scripts/check-plugin-teardown-shape.mjs: a Plugin implementation that declares a teardown-shaped method (stop / shutdown / close / dispose) and no destroy() fails, because destroy() is the only teardown hook ObjectKernel.performShutdown() and LiteKernel.destroy() call. Wired into lint.yml's already-required 'Lint & Repo Gates' job as a node scripts/... step (root package.json is inside the #9465 fence), self-test leg first. LANDING HAZARD: #10371 has NOT landed - verified by reading the code at origin/main 78ac958552, not the card - so the guard lands with the pre-existing instances in a shrink-only KNOWN_TEARDOWN_UNREACHED list, each entry naming #10371, and the failure text REFUSES new members outright rather than marking them (check:ratchet-remedy-authority classifies it 'refused', its fourth such instance; its CONTROL corpus gained the required declaration entry - the one bounded edit outside the new file). The baseline is ELEVEN, not six: re-deriving the population rather than adopting #10371's list found five instances that card does not name - MetadataPlugin / AppPlugin / ExternalValidationPlugin spell the alias as an arrow PROPERTY (a method-only scan misses them) and EmailServicePlugin / WebhookOutboxPlugin spell it dispose(), i.e. the 'seventh arrives spelled differently' case is already present. ExternalValidationPlugin owns setInterval timers that shutdown never clears. Filed as #10772 (sub-issue of #10371), not repaired here.", "tests": "All run at final HEAD b97bab513d, working tree clean, exit codes captured before any pipe (cmd > file 2>&1; EXIT=$?); every verdict below is the gate's own printed line, not a bare $?.\n\nNEW GATE. `node scripts/check-plugin-teardown-shape.mjs --self-test` EXIT=0 -> 'check-plugin-teardown-shape self-test: 47 cases pass (real pre-#10375 fixture reds, the repaired file and both delegating-alias directions stay green, every roster name reds, every excluded name stays green, and all five refusals are paired with a tree that still returns a verdict).' `node scripts/check-plugin-teardown-shape.mjs` EXIT=0 -> 'check:plugin-teardown-shape: 54 Plugin implementation(s) across 4371 source(s) under packages/**; every teardown-shaped method (stop / shutdown / close / dispose) sits beside a real destroy() (11 known-unreached, SHRINK-ONLY, repair tracked on #10371).'\n\nPOSITIVE CONTROL is the real fixture, per the card: MessagingServicePlugin extracted with `git show` at 621a487607881c66b2899b7e3477115229a156b4 (PR #10375's squash-merge first parent) - async stop(), no destroy() - reds with exactly one finding. Pinned to a COMMIT, not a checked-in copy, so the fixture cannot be edited into passing; an unreachable revision refuses and prints the deepen command instead of skipping. NEGATIVE CONTROLS: the same file after the #9371 repair (stop() delegating to this.destroy()) stays green; both delegating directions pinned synthetically; destroy()-with-no-alias green; ConnectorMcpPlugin's real `private close?: () => Promise<void>` handle shape green with AND without a destroy().\n\nREFUSALS, each paired with a readable tree that still returns a verdict so 'refuses unconditionally' cannot satisfy the battery: missing population root (paired tree still REDS), zero sources (paired tree returns green), zero Plugin implementations, an unreadable source (a dangling symlink named p.ts - EISDIR/chmod would not hold for root), and an unparseable source asserted OUT OF PROCESS. Deviation from the brief's literal wording, deliberate: an unparseable source exits EXIT_UNPARSEABLE (3) from scripts/ts-parse.mjs, not 1 - routing every parse through that module is what check:parse-guard enforces and it ends the process itself. The self-test asserts non-zero AND that the file is named.\n\nABLATION - prediction stated before running: blinding the red condition (`hasKernelHook: declared.has(KERNEL_HOOK)` -> `true`) reddens 10 of 47 cases. Observed: EXACTLY 10 of 47, and the named cases matched the enumeration one for one (real pre-#10375 fixture, all 4 roster names, the arrow-property spelling, the structural-Plugin case, the test-source case, the paired tree that must still red, and the live-tree ratchet agreement); the live gate also went red with 11 stale entries. NO BUILD IS INVOLVED - the gate is a plain .mjs run directly by node, resolving nothing through any package's dist/, so there is no rebuild leg to state and no dist marker to preflight. MUTATION CONFIRMED ON DISK by anchored grep counts, not by an editor exit code: before `original-anchor=1 injected=0`, after `original-anchor=0 injected=1`. RESTORE LEG confirmed the same way: `original-anchor=1 injected=0`, `git status --porcelain` empty, and the self-test back to 47/47 EXIT=0.\n\nDISPATCH-GATES NAMEABILITY (the #10705 trap). `node scripts/pm/dispatch-gates.mjs packages/plugins/plugin-reports/src/reports-plugin.ts` EXIT=0 prints under 'Local gates for this card': '- node scripts/check-plugin-teardown-shape.mjs [lint.yml] matched via packages/plugins/plugin-reports/src/reports-plugin.ts => gate source \\'packages/**\\''. MATCHED, not undetermined and not silent - the population is declared as the subtree spelling packages/**, never the bare word hintCovers refuses.\n\nGATE UNION re-derived with `node scripts/pm/dispatch-gates.mjs` (NO paths - it took the change set off the merge base itself: 3 paths vs merge base 78ac95855). All 14 legs green at b97bab513d: check-plugin-teardown-shape --self-test (47 cases) | check-plugin-teardown-shape | check:entry-guard ('126 scripts/ file(s) ... 10 known-unsafe' - ledger gained no member) | check:parse-guard ('125 scripts/ file(s) - every TypeScript parse goes through ts-parse.mjs') | check:ratchet-remedy-authority ('112 scripts swept ... 4 turn it down outright ... Control corpus: 20 hand-classified scripts, set-equality audited both ways') + its --self-test | check:required-contexts | check:aggregator-roster ('roster == needs: in both directions') | check:workflow-status-functions | check:cross-package-test-inputs | check:node-version | check:shard-attestation | check:type-check-coverage | check:nul-bytes ('scanned 6235 text file(s) ... no raw ASCII control bytes'), plus a manual `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` over the three edited files - no hits.\n\nDECLARED NARROWING: check:type-check-debt (check-type-check-coverage.mjs --re-measure) was NOT run. Attempted under scripts/pm/os-verify-lock.sh (waited 458s, acquired, ran); it REFUSED with '55 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk' - it needs the full built closure. This diff adds no package source and changes no tsconfig, so the frozen raw-error counts cannot move, and the leg of that gate which actually reads the lint.yml edited here (check-type-check-coverage.mjs, no flag) is green above. CI runs the re-measure on the built closure regardless.\n\nNO CHANGESET - a new gate publishes nothing. skip-changeset applied additively via POST /issues/10774/labels and READ BACK after the bots ran: ['ci/cd', 'size/l', 'skip-changeset']. PR body read back after creation and byte-identical to what was sent except a trimmed trailing newline; the session-URL signature footer survived. CI on PR #10774 is in_progress at report time - per the dispatch contract the report lands at draft-PR time and CI convergence is the PM's read.", "open_questions": [], "out_of_scope_findings": [ "filed as #10772: five more Plugin implementations release teardown from a hook the kernel never calls - MetadataPlugin, AppPlugin, ExternalValidationPlugin (arrow-property `stop`), EmailServicePlugin, WebhookOutboxPlugin (`dispose`) - the #10371 enumeration is short by five; linked as a sub-issue of #10371, unassigned, labels domain:services + bug, no pm:queue. ExternalValidationPlugin owns setInterval drift-check timers that kernel shutdown never clears, which is the #9371 mechanism verbatim." ] }Generated by Claude Code
chore(tooling): drop this card's six entries from KNOWN_TEARDOWN_UNRE…
fix(plugin-email): EmailServicePlugin tears down from destroy(), not …
fix(plugins,connectors,services): release plugin resources from destr…
fix(metadata,runtime,plugins): five more Plugin implementations tear …
docs(plugin-email): re-anchor the dead tracker citations to the commi…
docs(plugin-webhooks): re-anchor the dead tracker citation to the com…