Initial Commit
This commit is contained in:
165
openspec/specs/api-lifecycle/spec.md
Normal file
165
openspec/specs/api-lifecycle/spec.md
Normal file
@@ -0,0 +1,165 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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.
|
||||
|
||||
#### Scenario: every documented transition is legal
|
||||
|
||||
- **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"`
|
||||
Reference in New Issue
Block a user