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

9.4 KiB

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"