Files
suaveplan/openspec/archive/2026-05-25-api-lifecycle/specs/api-lifecycle/spec.md
2026-05-27 23:29:53 +01:00

8.4 KiB

Capability Spec Delta: api-lifecycle (api-lifecycle)

Package: @suaveplan/api-lifecycle Status: PROPOSED

Every requirement carries a **Verified by:** line.

ADDED Requirements

Requirement: PluginState union pinned to design §4

The package SHALL export PluginState (re-exported from @suaveplan/api-types) with exactly: "uninstalled" | "installing" | "installed" | "starting" | "started" | "rolled_back" | "crashed" | "stopping" | "stopped" | "uninstalling" | "install_failed" | "start_failed" | "uninstall_failed". The package SHALL also export PLUGIN_STATES as a runtime tuple matching the union.

Scenario: union matches design §4 exactly

  • WHEN expectTypeOf<PluginState>() is checked against the 13 literals
  • THEN the union is exactly those 13 — no more, no less

Scenario: runtime tuple matches the union

  • WHEN the test asserts PLUGIN_STATES covers every union member
  • THEN every member is present in the tuple

Verified by: src/states/states.spec-conformance.test.ts::"PluginState union matches design §4 exactly" AND src/states/states.test.ts::"PLUGIN_STATES tuple covers every union member"


Requirement: Transitions table is exhaustive

The package SHALL export PluginTransitions — a frozen (from, to, trigger) table. Every transition documented in design §4's table MUST be present. A transition not in the table MUST throw LIFECYCLE.ILLEGAL_TRANSITION when attempted.

  • WHEN the test enumerates the §4 table
  • THEN every documented (from, to) pair has an entry in PluginTransitions

Scenario: undocumented transition rejected

  • WHEN transition({ from: "installed", to: "stopped" }) is attempted (illegal: must pass through starting/stopping)
  • THEN LIFECYCLE.ILLEGAL_TRANSITION is thrown AND details.from === "installed" AND details.to === "stopped"

Verified by: src/transitions/transitions.test.ts::"every documented transition is legal" AND src/transitions/transitions.test.ts::"undocumented transition rejected"


Requirement: createPluginFsm wires @suaveplan/fsm

The package SHALL export createPluginFsm({ initial, sideEffects, mutex }) returning a Fsm<PluginState, PluginTrigger> backed by @suaveplan/fsm. Transitions MUST honour the mutex contract (FIFO queue, at most one in-flight per plugin).

Scenario: fsm starts in declared initial state

  • WHEN createPluginFsm({ initial: "uninstalled", ... }) is created
  • THEN fsm.state === "uninstalled"

Scenario: concurrent triggers queue

  • WHEN two install triggers are dispatched concurrently
  • THEN the second waits until the first completes AND completes against the post-first state (likely rejecting if the FSM left uninstalled)

Verified by: src/fsm-factory/fsm-factory.test.ts::"fsm starts in declared initial state" AND src/mutex-contract/mutex-contract.test.ts::"concurrent triggers queue FIFO"


Requirement: Side-effect descriptors per transition

The package SHALL export SIDE_EFFECTS: a map from (from, to) to the documented side-effect list. installing → installed MUST emit plugin.installed and complete the OTel span. started → stopping MUST pause jobs, unsubscribe events, unmount routes with 503 + Retry-After, call user stop hook with gracefulMs (default 30000). stopping → stopped MUST force-cancel stragglers AND dispose capabilities in reverse-registration order. Every transition documented in §4's table MUST appear here.

Scenario: every documented side-effect is in the table

  • WHEN the test compares SIDE_EFFECTS keys against §4's transition table
  • THEN every documented transition has an entry

Scenario: capability disposal order is reverse-registration

  • WHEN the stopping → stopped descriptor is inspected
  • THEN it declares disposalOrder: "reverse-registration"

Verified by: src/side-effects/side-effects.test.ts::"every documented side-effect is in the table" AND src/side-effects/side-effects.test.ts::"stopping→stopped disposes in reverse-registration order"


Requirement: Retry policies

The package SHALL export RETRY_POLICIES.startFailed (3 attempts, exponential backoff via @suaveplan/retry) and RETRY_POLICIES.crashed (default crashRestartMax: 3, crashRestartWindowMs, crashRestartCooldownMs). The crashed budget MUST reset after crashRestartCooldownMs of healthy operation. Exhausting any budget MUST surface LIFECYCLE.RETRY_BUDGET_EXHAUSTED.

Scenario: start_failed retried 3 times then parked

  • WHEN the start hook throws 4 times in a row
  • THEN the 4th attempt does NOT retry; the plugin is parked AND LIFECYCLE.RETRY_BUDGET_EXHAUSTED is the failure code

Scenario: crashed budget resets after cooldown

  • WHEN a plugin crashes twice within the window, then runs healthily for crashRestartCooldownMs, then crashes again
  • THEN the post-cooldown crash is treated as the first crash in a new budget window

Verified by: src/retry-policies/retry-policies.test.ts::"start_failed retried 3 times then parked" AND src/retry-policies/retry-policies.test.ts::"crashed budget resets after cooldown"


Requirement: requiresReinstall criteria

The package SHALL export requiresReinstall(prev: PluginManifest, next: PluginManifest): boolean returning true IFF any of: next.database.schemaVersion > prev.database.schemaVersion; next.scopes adds names not in prev.scopes; next.events.publishes or next.events.subscribes add new topics; next.jobs.cron or next.jobs.queued add new names; next.secrets adds a new secret; next.dependsOn differs from prev.dependsOn. A downgrade (next.version < prev.version) MUST throw LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE.

Scenario: identical manifest returns false

  • WHEN prev and next are deep-equal
  • THEN requiresReinstall(prev, next) === false

Scenario: schemaVersion bump returns true

  • WHEN next.database.schemaVersion is greater than prev's
  • THEN the result is true

Scenario: version downgrade rejected

  • WHEN semver.lt(next.version, prev.version) is true
  • THEN the call throws LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE

Verified by: src/reinstall-criteria/reinstall-criteria.test.ts::"identical manifest returns false" AND src/reinstall-criteria/reinstall-criteria.test.ts::"schemaVersion bump returns true" AND src/reinstall-criteria/reinstall-criteria.test.ts::"version downgrade throws"


Requirement: Mutex contract

The package SHALL export PluginMutex interface: acquire(plugin: string): Promise<Release>, tryAcquire(plugin: string): Release | null. At most one acquired release per plugin. Concurrent acquires queue FIFO. The interface is types-only; concrete impl lives in api-host.

Scenario: every documented method present

  • WHEN expectTypeOf<PluginMutex>() is checked
  • THEN acquire and tryAcquire are declared

Scenario: tryAcquire returns null when held

  • WHEN the conformance harness exercises a stub: acquire then tryAcquire
  • THEN the second call returns null

Verified by: src/mutex-contract/mutex-contract.spec-conformance.test.ts::"PluginMutex declares acquire tryAcquire" AND src/mutex-contract/mutex-contract.test.ts::"tryAcquire returns null when held"


Requirement: Error codes registry frozen

The package SHALL export LIFECYCLE_CODES containing exactly: ILLEGAL_TRANSITION, MUTEX_HELD, RETRY_BUDGET_EXHAUSTED, REINSTALL_REQUIRED_VERSION_DOWNGRADE.

Scenario: every code reachable

  • WHEN the test enumerates LIFECYCLE_CODES
  • THEN every code has a production throw site

Verified by: src/error-codes/error-codes.spec-conformance.test.ts::"every LIFECYCLE_CODES entry thrown by production path"


Requirement: Conformance harness for host driver

The package SHALL export runLifecycleConformance({ factory, label }). The Stage-2 api-host driver MUST pass it.

Scenario: harness covers states, transitions, side-effects, retry budgets, mutex, reinstall criteria

  • WHEN the harness runs against a stub driver
  • THEN test categories exist for each documented behavior

Verified by: src/testing/conformance.test.ts::"harness covers states transitions side-effects retry-budgets mutex reinstall-criteria"