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. (Seeopenspec/AGENTS.mdSection 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
parseManifestthrowsMANIFEST.PARSE_FAILEDAND the Zod issue path includesbadKey
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/**/*.tsforimport 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
SuaveplanErrorwith codeMANIFEST.PARSE_FAILEDANDerror.cause.issuesis 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 isMANIFEST.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"ANDdependsOndoes not contain@acme/unknown - THEN
validateSagaOwnershipreturns{ ok: false, error: { code: "MANIFEST.INVALID_SAGA_OWNERSHIP" } }
Scenario: sandboxed isolation without budgets rejected
- WHEN
isolation: "sandboxed"ANDpermissions.budgetsis absent - THEN
validatePermissionsWhenSandboxedreturnsMANIFEST.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_CYCLEis thrown AND the error'sdetails.pathis["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.requiredCapabilitiesis["db"]ANDregisteredis[] - THEN resolution returns
{ ok: false, error: { code: "MANIFEST.CAPABILITY_REQUIRED_MISSING", details: { missing: ["db"] } } }
Scenario: missing optional capability surfaces as warning
- WHEN
manifest.optionalCapabilitiesis["secrets.bun"]ANDregisteredlacks 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_CODESand greps each into the production source - THEN every code has at least one production-path reference outside
error-codes.tsitself
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"