Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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"`