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_STATEScovers 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.
Scenario: every documented transition is legal
- WHEN the test enumerates the §4 table
- THEN every documented
(from, to)pair has an entry inPluginTransitions
Scenario: undocumented transition rejected
- WHEN
transition({ from: "installed", to: "stopped" })is attempted (illegal: must pass throughstarting/stopping) - THEN
LIFECYCLE.ILLEGAL_TRANSITIONis thrown ANDdetails.from === "installed"ANDdetails.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
installtriggers 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_EFFECTSkeys against §4's transition table - THEN every documented transition has an entry
Scenario: capability disposal order is reverse-registration
- WHEN the
stopping → stoppeddescriptor 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_EXHAUSTEDis 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
prevandnextare deep-equal - THEN
requiresReinstall(prev, next) === false
Scenario: schemaVersion bump returns true
- WHEN
next.database.schemaVersionis greater thanprev'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
acquireandtryAcquireare 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"