Skip to content

v0.33.0

Choose a tag to compare

@vikasagarwal101 vikasagarwal101 released this 26 Jul 14:21

Deepen: Plugin Activation Contract

v0.33 is an architecture-deepening release that makes the customHttpRoute plugin kind's boot-time failure contract honest and bounded. Structural route faults (duplicate, malformed, or unsupported method/path) are now rejected at plugin load — the server boots without the offending plugin instead of crashing at Fastify mount time. Execution faults (routeHandlers throws at mount) are honestly crash-loud: the cosmetic rollback machinery is removed, the boot phase is extracted into a testable runPluginBoot, and the operator-facing log distinguishes fatal route-mount failure from non-fatal plugin-load failure. Per-contribution execution-fault isolation (a probe instance) is deferred after a 3-seat scope debate — the probe's core soundness property is mechanically unevaluable in JavaScript.

Before v0.33, customHttpRoute was a Tier-C contribution kind with no collision tracking, no field validation, and a cosmetic try/catch rollback in initializePlugins that implied graceful per-plugin isolation but was theater — Fastify poisons the instance on a plugin-execution failure, so listen() rejects regardless of any catch. Two plugins declaring the same (method, path) both loaded and then crashed boot at mount time. A plugin declaring method: 123 (non-string) threw a TypeError that aborted the entire loadPlugins() scan with no pluginErrors entry, silently preventing later plugins from loading. The operator saw "Failed to load plugins - continuing without plugins" for a fatal route-mount failure — a misleading message for a crash that was actually terminal.


Structural Collision Detection at Load

customHttpRoute joins the load-time collision-detection surface via the data-driven CATALOG (contributionAdapters.ts), mirroring notificationChannel and webhookFormatter. A new collision-only customHttpRouteRegistry Map tracks cross-plugin route ownership; the adapter gains collisionKey, collisions, and register fields. Within-manifest and cross-plugin (method, path) duplicates reject the whole plugin at validation time — the scan continues to the next plugin, and the server boots without the offender.

The collision key uppercases the method (${method.toUpperCase()} ${path}) to match Fastify's case-insensitive method handling; the path is byte-equal as-declared. Path-case and trailing-slash variants are a documented accepted hole — Fastify treats /Foo and /foo as distinct routes (both load silently), so they are not caught at load and do not crash at mount.

Crash-Loud Execution Contract

The initializePlugins try/catch+rollback is removed. A throwing routeHandlers now propagates as a boot-aborting error — fail-fast, no isolation theater. The rollback's unregisterContributions function is deleted entirely (zero callers after the rollback removal; module-private, not exported). An inline comment documents the crash-loud contract so a future reader doesn't re-introduce a rollback "for safety."

Structural Validation

Two cold code reviews (design + code, by independent fresh agents) caught that the initial implementation didn't fully deliver ADR-0041's "structural faults rejected at load" promise:

  • Field-shape validation (F1): customHttpRoute.orphanCheck validates method and path are non-empty strings before the collision-key construction calls c.method.toUpperCase(). Without this, a non-string method threw TypeError and rejected the entire loadPlugins() promise — no pluginErrors entry, later plugins undiscovered. Now the malformed plugin is rejected at validation; the scan continues.
  • Method-membership validation (F1b): orphanCheck validates method is one of the four supported HTTP methods (GET, POST, PATCH, DELETE — case-insensitive, matching the collision key's uppercase normalization). Before this, any non-empty string passed — TRACE, BOGUS, etc. loaded despite contradicting the CustomHttpRouteContribution type union. Now unsupported methods reject at validation per ADR-0041.

Both validations run at the validatePlugin stage via the existing orphanCheckpluginErrors chain — a malformed manifest is rejected per-plugin (scan continues), never reaching collision-key construction or registration.

Boot-Phase Extraction

The two boot catch regimes (non-fatal loadPlugins, fatal initializePlugins) are extracted from index.ts into a testable runPluginBoot(fastify) in pluginBoot.ts. The extraction preserves the exact boot sequence (loadQuarantinesFromDbloadPluginsinitializePluginsinitDaemonWiringlisten) and makes the two-regime contract unit-testable without spawning the compiled server.

Three mutation-checked tests pin the contract: the fatal regime logs "Plugin route initialization failed - server cannot boot" and calls process.exit(1); the non-fatal regime logs "continuing without plugins" and boot continues; re-merging the catches, swapping the fatal message, or dropping process.exit(1) all fail the suite.

Scope Debate: Why the Probe Is Deferred

Per-contribution execution-fault isolation — a probe instance that attempts routeHandlers on a throwaway Fastify and discards only the route contribution while keeping the plugin's channel/detector/interceptor contributions live (mirroring ADR-0039's per-contribution quarantine at runtime) — was seriously considered and then deferred after a structured 3-seat scope debate.

The pro-expand seat (on the stronger codex model, deliberately chosen to give expansion its best hearing) conceded after cross-examination exposed a decisive soundness gap: the probe's core promise — "probe-mount success ⇒ live-mount success" — is mechanically unevaluable in JavaScript. A plugin's mount-time code can perform non-idempotent side effects (e.g., dbConnection = await open(...) at registration) that leave no detectable trace. The probe runs the callback, the live instance runs it again, route tables match, the conformance harness reports equivalence — and a resource is leaked. The probe passes its own checks while being unsound. No Fastify version closes this; no JS runtime has effect-tracking.

ADR-0041's revisit bar is tightened accordingly: reopening requires a source-auditable plugin whose mount-time behavior is audited as side-effect-free or idempotent under re-execution. This narrows rather than closes the bar — pure-registration code that throws for environmental reasons (undefined path, Fastify-version quirk) remains a valid trigger.

What Stayed the Same

No new contribution kinds, endpoints, MCP tools, or UI changes. Plugin Manifest format unchanged. Plugin Enrollment REST API unchanged. routeHandlers remains a single FastifyPluginCallback per plugin (ADR-0011 intact). The four adapter-only kinds (customMcpTool, webhookFormatter, automationCondition, integrationProvider) and all five managed kinds retain their existing registration and dispatch regimes. The invocation runtime (ADR-0039) is untouched. 1 ADR (0041). No schema changes, no migrations.

Commits

  • 0b7f030 — feat(plugins): customHttpRoute collision detection at load (ADR-0041)
  • 4e8df0b — fix(plugins): make routeHandlers mount failure crash-loud (ADR-0041)
  • a99e33f — fix(plugins): reject malformed customHttpRoute method/path at validation
  • f46f519 — refactor(plugins): extract plugin boot phase into runPluginBoot
  • 1448ce9 — fix(plugins): reject unsupported customHttpRoute methods at validation
  • cd90cc1 — docs: reflect v0.33.0 Plugin Activation Contract delivery + fix plugin-runtime doc drift