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

161 lines
9.4 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-manifest (api-manifest)
**Package**: `@suaveplan/api-manifest`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: Plugin manifest Zod schema covers every documented field
The package SHALL export `pluginManifestSchema: z.ZodSchema<PluginManifest>` covering every top-level manifest key documented in design §3: identity (`$schema`, `name`, `version`, `displayName`, `description`, `license`), compat (`apiHost`, `requiredCapabilities`, `optionalCapabilities`), `isolation`, `trustLevel`, `permissions`, `dependsOn`, `routes`, `graphql`, `websockets`, `sse`, `sagas`, `twoPhaseCommit`, `database`, `jobs`, `events`, `secrets`, `config`, `scopes`, `telemetry`, `openapi`, `errors`, `uninstall`. The inferred type MUST be structurally assignable to `PluginManifest` from `@suaveplan/api-types`. Unknown top-level keys MUST be rejected (`.strict()`).
#### Scenario: minimal manifest parses
- **WHEN** the smallest legal manifest (`{ name, version, apiHost }`) is parsed
- **THEN** parsing succeeds AND optional fields are absent (NOT defaulted to empty objects)
#### Scenario: maximal manifest parses
- **WHEN** the full §3 example is parsed
- **THEN** every documented field round-trips with the documented type
#### Scenario: unknown top-level key rejected
- **WHEN** `{ name, version, apiHost, badKey: 1 }` is parsed
- **THEN** `parseManifest` throws `MANIFEST.PARSE_FAILED` AND the Zod issue path includes `badKey`
**Verified by:** `src/schema/plugin-manifest.test.ts::"minimal manifest parses"` AND `src/schema/plugin-manifest.test.ts::"maximal manifest round-trips every field"` AND `src/schema/plugin-manifest.spec-conformance.test.ts::"rejects unknown top-level keys"`
---
### Requirement: `import z from "zod"` everywhere
Every `.ts` file using Zod MUST use the default-import form (`import z from "zod"`). The named-import form (`import z from "zod"`) is forbidden — Bun's monorepo resolution treats the named form as unstable.
#### Scenario: no named import of `z`
- **WHEN** the test greps every `src/**/*.ts` for `import z`
- **THEN** zero matches are returned
**Verified by:** `tests/zod-import-form.test.ts::"every src file uses default-import z"`
---
### Requirement: `parseManifest` and `parseManifestSafe`
The package SHALL export `parseManifest(input: unknown): PluginManifest` (throws `SuaveplanError` with code `MANIFEST.PARSE_FAILED` on failure) and `parseManifestSafe(input: unknown): { ok: true; data: PluginManifest } | { ok: false; error: SuaveplanError }`. The thrown / returned error MUST carry the raw Zod issues array under `cause.issues`.
#### Scenario: throw form on invalid input
- **WHEN** `parseManifest({})` is called
- **THEN** it throws `SuaveplanError` with code `MANIFEST.PARSE_FAILED` AND `error.cause.issues` is a non-empty array
#### Scenario: safe form preserves the error envelope
- **WHEN** `parseManifestSafe({})` is called
- **THEN** the result is `{ ok: false, error }` AND the error's code is `MANIFEST.PARSE_FAILED`
**Verified by:** `src/parse/parse.test.ts::"throw form on invalid input"` AND `src/parse/parse.test.ts::"safe form preserves error envelope"`
---
### Requirement: Cross-field validators that Zod alone can't express
The package SHALL export `validateSagaOwnership`, `validatePermissionsWhenSandboxed`, `validateOpenApiRequiresGraphqlOrRoutes`, and `validateErrorStatusMapShape`. Each validator takes a parsed `PluginManifest` and returns `{ ok: true } | { ok: false; error: SuaveplanError }` with a stable code. `validateSagaOwnership` MUST reject sagas whose steps reference a `plugin` name that is not the manifest's own name and not declared in `dependsOn`. `validatePermissionsWhenSandboxed` MUST reject `isolation: "sandboxed"` manifests missing `permissions.budgets`.
#### Scenario: saga step referencing unknown plugin rejected
- **WHEN** a saga lists a step with `plugin: "@acme/unknown"` AND `dependsOn` does not contain `@acme/unknown`
- **THEN** `validateSagaOwnership` returns `{ ok: false, error: { code: "MANIFEST.INVALID_SAGA_OWNERSHIP" } }`
#### Scenario: sandboxed isolation without budgets rejected
- **WHEN** `isolation: "sandboxed"` AND `permissions.budgets` is absent
- **THEN** `validatePermissionsWhenSandboxed` returns `MANIFEST.SANDBOX_REQUIRES_BUDGETS`
**Verified by:** `src/validators/validate-saga-ownership.test.ts::"rejects step referencing undeclared dependency"` AND `src/validators/validate-permissions-when-sandboxed.test.ts::"sandboxed isolation requires permissions.budgets"`
---
### Requirement: `dependsOn` topological resolver
The package SHALL export `resolveInstallOrder(manifests: readonly PluginManifest[]): readonly PluginManifest[]` returning the manifests in dependency order (dependencies before dependents). Cycles MUST surface `MANIFEST.DEPENDENCY_CYCLE` with the cycle path attached. Missing dependencies (`dependsOn` references a name not in the set) MUST surface `MANIFEST.MISSING_DEPENDENCY`. Version-range mismatches (semver) MUST surface `MANIFEST.DEPENDENCY_VERSION_MISMATCH`.
#### Scenario: order is deterministic regardless of input order
- **WHEN** the same set of manifests is shuffled and resolved
- **THEN** the output order is identical across every shuffle
#### Scenario: cycle reported with the cycle path
- **WHEN** A depends on B AND B depends on A
- **THEN** `MANIFEST.DEPENDENCY_CYCLE` is thrown AND the error's `details.path` is `["A", "B", "A"]`
**Verified by:** `src/dep-graph/dep-graph.test.ts::"order is deterministic across shuffles"` AND `src/dep-graph/dep-graph.test.ts::"reports cycle path in details"`
---
### Requirement: Capability resolver
The package SHALL export `resolveCapabilities({ manifest, registered }): CapabilityResolution`. `registered` is a `readonly CapabilityDescriptor[]` (each with `id`, `version`). Missing required capabilities MUST be reported as errors with code `MANIFEST.CAPABILITY_REQUIRED_MISSING` containing the missing id list. Missing optional capabilities MUST be reported as warnings (resolution still succeeds). Version mismatches between manifest's `apiHost` semver and the registered descriptor's version MUST surface `MANIFEST.CAPABILITY_VERSION_MISMATCH`.
#### Scenario: missing required capability blocks resolution
- **WHEN** `manifest.requiredCapabilities` is `["db"]` AND `registered` is `[]`
- **THEN** resolution returns `{ ok: false, error: { code: "MANIFEST.CAPABILITY_REQUIRED_MISSING", details: { missing: ["db"] } } }`
#### Scenario: missing optional capability surfaces as warning
- **WHEN** `manifest.optionalCapabilities` is `["secrets.bun"]` AND `registered` lacks it
- **THEN** resolution returns `{ ok: true, warnings: [{ code: "MANIFEST.OPTIONAL_CAPABILITY_MISSING", details: { id: "secrets.bun" } }] }`
**Verified by:** `src/capability-resolver/capability-resolver.test.ts::"missing required capability blocks resolution"` AND `src/capability-resolver/capability-resolver.test.ts::"missing optional surfaces as warning"`
---
### Requirement: Canonical manifest hash
The package SHALL export `hashManifest(manifest: PluginManifest): string` returning `"sha256:<hex>"`. The hash MUST be computed over a canonical-JSON encoding: keys sorted lexicographically at every depth, no whitespace, no trailing newline. Two manifests differing only in key insertion order MUST hash identically. Two manifests differing in any value (including nested arrays' order if the array is semantically ordered) MUST hash differently.
#### Scenario: key-order independence
- **WHEN** two manifests with identical content but different top-level key order are hashed
- **THEN** the hashes are equal
#### Scenario: KAT vector
- **WHEN** the minimal manifest `{ name: "x", version: "0.1.0", apiHost: "^1" }` is hashed
- **THEN** the output equals a published vector pinned in the test (so this never silently drifts)
**Verified by:** `src/hash/hash.test.ts::"key-order independent"` AND `src/hash/hash.test.ts::"known-answer vector for minimal manifest"`
---
### Requirement: Error codes registry frozen
The package SHALL export `MANIFEST_CODES` via `defineCodes` (from `@suaveplan/error`) containing exactly: `PARSE_FAILED`, `DEPENDENCY_CYCLE`, `MISSING_DEPENDENCY`, `DEPENDENCY_VERSION_MISMATCH`, `CAPABILITY_REQUIRED_MISSING`, `CAPABILITY_VERSION_MISMATCH`, `OPTIONAL_CAPABILITY_MISSING`, `INVALID_SAGA_OWNERSHIP`, `SANDBOX_REQUIRES_BUDGETS`, `INVALID_ERROR_STATUS_MAP`. Every code MUST be thrown (or attached to a warning) by at least one production code path.
#### Scenario: every code is reachable
- **WHEN** the test enumerates `MANIFEST_CODES` and greps each into the production source
- **THEN** every code has at least one production-path reference outside `error-codes.ts` itself
**Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every MANIFEST_CODES entry is thrown by a production path"`
---
### Requirement: Subpath exports per concern
Every category (`schema`, `parse`, `validators`, `dep-graph`, `capability-resolver`, `hash`, `error-codes`, `testing`) SHALL be reachable via its own subpath export. The root barrel MUST use explicit named re-exports (rule 10) — no `export *`.
#### Scenario: every documented subpath resolves
- **WHEN** the test imports each subpath
- **THEN** every import resolves at type-check + runtime
**Verified by:** `tests/subpaths.test.ts::"every documented subpath is importable"` AND `tests/dep-graph.test.ts::"root barrel has no export-star"`