first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
# @suaveplan/api-manifest - Zod manifest schema, parser, cross-field validators, dep-graph resolver, capability resolver, and canonical hasher for Suaveplan API plugin system
Stage-1 contract package of the Suaveplan API plugin system: Zod schemas, parser, cross-field validators, dependency-graph resolver, capability resolver, and canonical hasher for the `plugin.json` manifest documented in
`docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md` §3.
This package is the single source of truth for the declarative half of every plugin contract. Every consumer (host loader, admin API, CLI scaffolder, OpenAPI emitter, `definePlugin` typings) imports from here — the schema does not live in any single host implementation.
<!-- begin:auto-docs -->
## Documentation tree
- [capability-resolver](./src/capability-resolver/capability-resolver.md) — Decide whether a plugin can boot against the host's registered capability set. Missing required capabilities block reso…
- [capability-resolver](./src/capability-resolver/capability-resolver.md) — Decide whether a plugin can boot against the host's registered capability set. Missing required capabilities block reso…
- [dep-graph](./src/dep-graph/dep-graph.md) — Topologically order a set of plugin manifests so each plugin's `dependsOn` entries precede it. Deterministic — lexicogr…
- [dep-graph](./src/dep-graph/dep-graph.md) — Topologically order a set of plugin manifests so each plugin's `dependsOn` entries precede it. Deterministic — lexicogr…
- [semver](./src/dep-graph/semver.md) — Small in-house semver-range matcher used by the dep-graph and capability resolvers. Covers a deliberately narrow subset…
- [error-codes](./src/error-codes/error-codes.md) — Frozen `MANIFEST.*` error-code registry plus the concrete `SuaveplanError` subclass thrown by every public path in this…
- [error-codes](./src/error-codes/error-codes.md) — Frozen `MANIFEST.*` error-code registry plus the concrete `SuaveplanError` subclass thrown by every public path in this…
- [hash](./src/hash/hash.md) — Compute `sha256:<hex>` over the canonical-JSON encoding of a manifest. Used by `RollbackJournal` entries and the plugin…
- [canonical-json](./src/hash/canonical-json.md) — Canonical JSON encoder used by `hashManifest`. Object keys are sorted lexicographically at every depth; array order is…
- [hash](./src/hash/hash.md) — Compute `sha256:<hex>` over the canonical-JSON encoding of a manifest. Used by `RollbackJournal` entries and the plugin…
- [parse](./src/parse/parse.md) — Two top-level entry points for converting an unknown JSON value into a typed `PluginManifest`. `parseManifest` is the t…
- [parse](./src/parse/parse.md) — Two top-level entry points for converting an unknown JSON value into a typed `PluginManifest`. `parseManifest` is the t…
- [compat](./src/schema/compat.md) — Validates the compatibility block of a plugin manifest: the `apiHost` semver range and the two capability arrays (`requ…
- [config](./src/schema/config.md) — Plugin-level config block. `schemaPath` points at a JSON Schema the host validates user-supplied config against at inst…
- [database](./src/schema/database.md) — Database-wiring block. Pins whether the plugin needs a database connection, which engines it supports, its current sche…
- [depends-on](./src/schema/depends-on.md) — Map of plugin-name → semver range used by the dep-graph resolver. Names follow the same npm-package convention as `iden…
- [errors](./src/schema/errors.md) — Plugin-error status-map block. Maps `PLUGIN.<DOMAIN>.<REASON>` codes to HTTP status numbers and optional redaction path…
- [events](./src/schema/events.md) — Event-bus topology declaration. Lists the subjects this plugin publishes and the subjects it subscribes to. Subjects fo…
- [graphql](./src/schema/graphql.md) — Optional GraphQL contribution block. When `contributes` is `true`, the host loads the SDL at `types` (path relative to…
- [identity](./src/schema/identity.md) — Zod schema for the identity fields of a plugin manifest — the keys that uniquely identify a plugin in the registry and…
- [isolation](./src/schema/isolation.md) — Two Zod enum schemas covering the orthogonal isolation and trust axes of the manifest. Isolation determines how the hos…
- [jobs](./src/schema/jobs.md) — Background-job block: cron schedules and queued workers. Handlers live in the code half of the manifest; this block is…
- [openapi](./src/schema/openapi.md) — OpenAPI contribution block. When `contributes: true` the host emits a per-plugin OpenAPI document by introspecting the…
- [permissions](./src/schema/permissions.md) — Zod schemas for the sandbox permissions block. `permissions.fs` and `permissions.fetch` are host-proxied allowlists hon…
- [plugin-manifest](./src/schema/plugin-manifest.md) — Composite Zod schema joining every per-key sub-schema into a single strict-mode object. The inferred `PluginManifest` t…
- [routes](./src/schema/routes.md) — Validates the declarative HTTP-routes block. Carries the route `prefix` plus an optional `publicRoutes` array — routes…
- [sagas](./src/schema/sagas.md) — Saga ownership + participation block. `owns` lists the sagas this plugin orchestrates (each with a non-empty step list)…
- [scopes](./src/schema/scopes.md) — AuthZ scope declarations. Each entry pins a `resource:action` name and an optional human-readable description. The host…
- [secrets](./src/schema/secrets.md) — Declares the named secrets the plugin needs at boot. Names use SCREAMING_SNAKE_CASE so they map cleanly onto environmen…
- [sse](./src/schema/sse.md) — Server-Sent-Events endpoint declarations. Mirrors the WebSocket block's shape minus the heartbeat/connection cap — SSE…
- [telemetry](./src/schema/telemetry.md) — Telemetry-instrument declarations. The host pre-registers every named counter, histogram, and gauge so plugin code can…
- [two-phase-commit](./src/schema/two-phase-commit.md) — Two-phase-commit participation declaration. A plugin sets `participates: true` to opt into the host's 2PC coordinator;…
- [uninstall](./src/schema/uninstall.md) — Uninstall data-handling policy. Three modes: `archive` (move rows to a prefixed shadow table so they survive removal),…
- [websockets](./src/schema/websockets.md) — Validates the WebSocket endpoint declarations. Each entry pins a path, an optional auth scope, the set of subjects the…
- [testing](./src/testing/conformance.md) — Reusable conformance harness for downstream tooling (admin CLI, IDE plugin) that ships its own `parseManifest` implemen…
- [conformance](./src/testing/conformance.md) — Reusable conformance harness for downstream tooling (admin CLI, IDE plugin) that ships its own `parseManifest` implemen…
- [validate-error-status-map-shape](./src/validators/validate-error-status-map-shape.md) — Defence-in-depth re-check of the `errors.statusMap` shape. The Zod schema already rejects malformed entries; this valid…
- [validate-openapi-requires-graphql-or-routes](./src/validators/validate-openapi-requires-graphql-or-routes.md) — Rejects manifests that declare an OpenAPI contribution but expose neither HTTP routes nor a GraphQL contribution. The O…
- [validate-permissions-when-sandboxed](./src/validators/validate-permissions-when-sandboxed.md) — Enforces that a `sandboxed`-tier plugin declares `permissions.budgets`. Without budgets the host can't enforce CPU/memo…
- [validate-saga-ownership](./src/validators/validate-saga-ownership.md) — Cross-field validator that rejects sagas whose step list references a plugin neither owned by this manifest nor declare…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-manifest
```
## Subpath exports
Each concern is a separate subpath so consumers tree-shake to exactly what they use.
- [`./schema`](./src/schema/plugin-manifest.md) — `pluginManifestSchema`, the composite Zod schema, plus every per-key sub-schema.
- [`./parse`](./src/parse/parse.md) — `parseManifest` and `parseManifestSafe`.
- [`./validators`](./src/validators/validate-saga-ownership.md) — four cross-field validators that Zod alone can't express.
- [`./dep-graph`](./src/dep-graph/dep-graph.md) — `resolveInstallOrder` and the in-house `satisfies` semver matcher.
- [`./capability-resolver`](./src/capability-resolver/capability-resolver.md) — `resolveCapabilities`.
- [`./hash`](./src/hash/hash.md) — `hashManifest` and the underlying `canonicalJson` encoder.
- [`./error-codes`](./src/error-codes/error-codes.md) — `MANIFEST_CODES` registry and `ManifestError`.
- [`./testing`](./src/testing/conformance.md) — `runManifestConformance` harness and fixtures.
## Modules
### Schema
- [`identity`](./src/schema/identity.md), [`compat`](./src/schema/compat.md), [`isolation`](./src/schema/isolation.md), [`permissions`](./src/schema/permissions.md)
- [`depends-on`](./src/schema/depends-on.md), [`routes`](./src/schema/routes.md), [`graphql`](./src/schema/graphql.md)
- [`websockets`](./src/schema/websockets.md), [`sse`](./src/schema/sse.md), [`sagas`](./src/schema/sagas.md), [`two-phase-commit`](./src/schema/two-phase-commit.md)
- [`database`](./src/schema/database.md), [`jobs`](./src/schema/jobs.md), [`events`](./src/schema/events.md)
- [`secrets`](./src/schema/secrets.md), [`config`](./src/schema/config.md), [`scopes`](./src/schema/scopes.md)
- [`telemetry`](./src/schema/telemetry.md), [`openapi`](./src/schema/openapi.md), [`errors`](./src/schema/errors.md), [`uninstall`](./src/schema/uninstall.md)
- [`plugin-manifest`](./src/schema/plugin-manifest.md) — the composite schema.
### Parse, validators, graph, capability, hash, testing
- [`parse`](./src/parse/parse.md)
- [`validate-saga-ownership`](./src/validators/validate-saga-ownership.md)
- [`validate-permissions-when-sandboxed`](./src/validators/validate-permissions-when-sandboxed.md)
- [`validate-openapi-requires-graphql-or-routes`](./src/validators/validate-openapi-requires-graphql-or-routes.md)
- [`validate-error-status-map-shape`](./src/validators/validate-error-status-map-shape.md)
- [`dep-graph`](./src/dep-graph/dep-graph.md), [`semver`](./src/dep-graph/semver.md)
- [`capability-resolver`](./src/capability-resolver/capability-resolver.md)
- [`hash`](./src/hash/hash.md), [`canonical-json`](./src/hash/canonical-json.md)
- [`testing`](./src/testing/conformance.md)
- [`error-codes`](./src/error-codes/error-codes.md)
## Error codes
Every code is namespaced under `MANIFEST.*` and produced by `defineCodes`. The eleven codes — `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`, `OPENAPI_REQUIRES_GRAPHQL_OR_ROUTES` — are pinned by a spec-conformance test and each has at least one production-path reference.
## Dependencies
- `@suaveplan/api-types` — the type-level view of the manifest (zero runtime).
- `@suaveplan/error` — `SuaveplanError`, `defineCodes`.
- `@suaveplan/types` — shared logger/clock/id-generator contracts.
- `zod` — runtime schema engine.
## License
MIT

View File

@@ -0,0 +1,57 @@
# `resolveCapabilities`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Decide whether a plugin can boot against the host's registered capability set. Missing required capabilities block resolution; missing optional capabilities surface as warnings; version mismatches against the host's adapter version block resolution.
## Features
- Discriminated result type — `{ ok: true; warnings }` or `{ ok: false; error }`
- Optional `versionRanges` map drives per-capability semver checks
- Warnings carry the same code surface as errors
- Returns the full warning list (every missing optional, not just the first)
## Basic Example
```ts
import { resolveCapabilities } from "@suaveplan/api-manifest/capability-resolver";
resolveCapabilities({
manifest,
registered: [{ id: "db.transactions", version: "1.4.0" }],
});
```
## Advanced Example
```ts
import { resolveCapabilities, type CapabilityDescriptor } from "@suaveplan/api-manifest/capability-resolver";
const registered: CapabilityDescriptor[] = host.listCapabilities();
const result = resolveCapabilities({
manifest,
registered,
versionRanges: { "db.transactions": "^1" },
});
if (!result.ok) throw result.error;
for (const w of result.warnings) logger.warn(w.code, w.details);
```
## API Reference
- `resolveCapabilities(input: ResolveCapabilitiesInput): CapabilityResolution`
- Failure codes: `CAPABILITY_REQUIRED_MISSING`, `CAPABILITY_VERSION_MISMATCH`.
- Warning code: `OPTIONAL_CAPABILITY_MISSING`.
## Implementation Notes
Version ranges are supplied by the caller rather than the manifest because the manifest declares which capabilities it needs, not which versions — the host's compat policy decides the range. This keeps the manifest forward-compatible across host upgrades; only the host needs to be updated when a capability's wire format changes. Warnings are kept distinct from errors so the host can log them and continue boot, which is the whole point of marking a capability optional.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,53 @@
# `resolveInstallOrder`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Topologically order a set of plugin manifests so each plugin's `dependsOn` entries precede it. Deterministic — lexicographic tie-breaking guarantees identical output across input shuffles. Throws on cycles, missing deps, and version mismatches with stable codes that admin tooling renders.
## Features
- Kahn's algorithm with sorted ready-queue for deterministic output
- Cycle detection with the cycle path surfaced on `context.path`
- Missing-dep + duplicate-name detection
- Semver-range matching via the in-house `satisfies()` against actual versions
## Basic Example
```ts
import { resolveInstallOrder } from "@suaveplan/api-manifest/dep-graph";
const order = resolveInstallOrder([a, b, c]);
```
## Advanced Example
```ts
import { resolveInstallOrder, ManifestError, MANIFEST_CODES } from "@suaveplan/api-manifest";
try {
const order = resolveInstallOrder(manifests);
for (const m of order) installPlugin(m);
} catch (e) {
if (e instanceof ManifestError && e.code === MANIFEST_CODES.DEPENDENCY_CYCLE) {
console.error("cycle:", e.context?.path);
}
}
```
## API Reference
- `resolveInstallOrder(manifests: readonly PluginManifest[]): readonly PluginManifest[]`
- Failure codes: `DEPENDENCY_CYCLE`, `MISSING_DEPENDENCY`, `DEPENDENCY_VERSION_MISMATCH`.
## Implementation Notes
Determinism matters because the install order is part of the audit trail — a non-deterministic resolver would make `manifestHash` collisions between runs misleading. The cycle finder is a depth-first walk over the stuck set (the manifests whose in-degree never hit zero); it returns a path with head=tail so the consumer sees the cycle as a closed loop. Duplicate plugin names (same `name` field twice) surface as `MISSING_DEPENDENCY` because the second occurrence is treated as ambiguous-target.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,45 @@
# `satisfies`
## Purpose
Small in-house semver-range matcher used by the dep-graph and capability resolvers. Covers a deliberately narrow subset: pinned (`1.2.3`), caret (`^1.2.3`, `^1.2`, `^1`), tilde (`~1.2.3`, `~1.2`, `~1`), and the wildcards `*` / `latest`. Anything outside that subset throws so the caller can surface a stable error code.
## Features
- Strict SemVer 2.0.0 parse of the candidate version
- Caret semantics match npm's: `^0.x.y` stays inside the `0.x` line
- Tilde semantics: `~1.2.3` matches `1.2.>=3`, `~1.2` matches `1.2.*`
- No external dependency on `semver` — fewer bytes shipped to consumers
## Basic Example
```ts
import { satisfies } from "@suaveplan/api-manifest/dep-graph";
satisfies("1.4.2", "^1"); // true
```
## Advanced Example
```ts
import { satisfies } from "@suaveplan/api-manifest/dep-graph";
function matchAny(version: string, ranges: readonly string[]): boolean {
return ranges.some((r) => {
try { return satisfies(version, r); } catch { return false; }
});
}
```
## API Reference
- `satisfies(version: string, range: string): boolean`
- Throws `Error` (mapped by the dep-graph to `MANIFEST.DEPENDENCY_VERSION_MISMATCH`) on unsupported range syntax.
## Implementation Notes
The matcher uses three regexes (full SemVer, major-only, major.minor) and one comparator. Pre-release ordering follows SemVer 2.0.0 — pre-release strings sort lexically and any pre-release is less than the same `M.M.P` without a pre-release. Build metadata is parsed-but-ignored for comparison, matching npm. The deliberate narrowness keeps the comparator total (no `>=1.2.3 || <2.0.0`-style ambiguity); when v2 ships, range unions can be added without breaking the existing surface.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,61 @@
# `MANIFEST_CODES` and `ManifestError`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Frozen `MANIFEST.*` error-code registry plus the concrete `SuaveplanError` subclass thrown by every public path in this package. The registry is built with `defineCodes` so every entry is a literal-typed `MANIFEST.<REASON>` string, surviving tree-shaking and minification. `ManifestError` narrows the base class's `TCode` parameter to that union so `instanceof ManifestError` plus `error.code` is a discriminated union at the consumer site.
## Features
- Eleven stable codes covering parse, dep-graph, capability, and validator failures
- Literal-typed `ManifestCode` union derived from the registry
- `ManifestError` subclass preserving the base cause-chain, context, OTel adapter, and i18n message-key resolution
- Every code has at least one production-path reference enforced by a spec-conformance test
## Basic Example
```ts
import { MANIFEST_CODES, ManifestError } from "@suaveplan/api-manifest/error-codes";
throw new ManifestError(
MANIFEST_CODES.PARSE_FAILED,
"manifest.parse_failed",
{ context: { issues: [] } },
);
```
## Advanced Example
```ts
import {
MANIFEST_CODES,
type ManifestCode,
ManifestError,
} from "@suaveplan/api-manifest/error-codes";
function handle(err: unknown): ManifestCode | null {
if (err instanceof ManifestError) return err.code;
return null;
}
const code: ManifestCode = MANIFEST_CODES.DEPENDENCY_CYCLE;
```
## API Reference
- `MANIFEST_CODES` — `Readonly<Record<Reason, \`MANIFEST.${Reason}\`>>`.
- `ManifestCode` — string-literal union of every registered code.
- `ManifestError` — `class ManifestError extends SuaveplanError<ManifestCode>`.
## Implementation Notes
`defineCodes("MANIFEST", [...])` returns a frozen object so consumers cannot accidentally mutate it at runtime; combined with `as const` on the reason array the type system pins every key + value. The eleven codes are: `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`, `OPENAPI_REQUIRES_GRAPHQL_OR_ROUTES`. Adding a new code is an spec change because every reachable code is pinned by the spec-conformance test.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,44 @@
# `canonicalJson`
## Purpose
Canonical JSON encoder used by `hashManifest`. Object keys are sorted lexicographically at every depth; array order is preserved; whitespace is omitted; `undefined` properties are dropped. The encoder rejects non-finite numbers, bigints, and cyclic structures so two manifests with the same logical content always produce the same byte stream.
## Features
- Deep key-sort at every object depth
- Cycle detection via `WeakSet`
- Reject non-finite numbers and bigints
- `undefined`-as-value coerces to `null` to match JSON.stringify
## Basic Example
```ts
import { canonicalJson } from "@suaveplan/api-manifest/hash";
canonicalJson({ b: 1, a: 2 }); // '{"a":2,"b":1}'
```
## Advanced Example
```ts
import { canonicalJson } from "@suaveplan/api-manifest/hash";
import { createHash } from "node:crypto";
function etag(value: unknown): string {
return createHash("sha256").update(canonicalJson(value), "utf8").digest("hex");
}
```
## API Reference
- `canonicalJson(value: unknown): string` — throws on cycles, bigints, non-finite numbers, and `typeof` values JSON cannot represent (functions, symbols).
## Implementation Notes
Array order is preserved by design because saga step order is semantically meaningful — reordering steps would change the saga's behaviour, so two manifests with reordered steps must hash differently. `undefined` as a property value collapses to nothing (matching `JSON.stringify`); `undefined` as an array element coerces to `"null"` to keep the array length stable. The encoder is recursive but bounded by the tree depth; the WeakSet catches cycles before stack-overflow becomes a failure mode.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,49 @@
# `hashManifest`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Compute `sha256:<hex>` over the canonical-JSON encoding of a manifest. Used by `RollbackJournal` entries and the plugin ledger to detect drift between install and reload — a hash mismatch is a hard failure because the manifest is the host's source of truth for the plugin's declarative facts.
## Features
- SHA-256 via Node's `node:crypto` `createHash`
- Canonical JSON encoding via the sibling `canonicalJson` helper
- Hex-encoded digest with a `sha256:` prefix
- Key-order independence — only value differences alter the hash
## Basic Example
```ts
import { hashManifest } from "@suaveplan/api-manifest/hash";
const h = hashManifest(manifest);
```
## Advanced Example
```ts
import { hashManifest } from "@suaveplan/api-manifest/hash";
import { parseManifest } from "@suaveplan/api-manifest/parse";
function detectDrift(prevHash: string, raw: unknown): boolean {
const next = hashManifest(parseManifest(raw));
return next !== prevHash;
}
```
## API Reference
- `hashManifest(manifest: PluginManifest): string` returning `sha256:<64-hex>`.
## Implementation Notes
The `sha256:` prefix mirrors the multibase-style convention used elsewhere in the crypto stack so callers can identify the algorithm without sniffing the digest length. Using `node:crypto` keeps the dependency surface to zero — the rollback journal lives in the host (server tier) so this is acceptable. If a future browser-tier consumer needs the same hash, a thin SubtleCrypto adapter can be added behind a conditional export.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,51 @@
# `parseManifest` and `parseManifestSafe`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Two top-level entry points for converting an unknown JSON value into a typed `PluginManifest`. `parseManifest` is the throw-form (suitable for the install gate where bad input is a fatal failure); `parseManifestSafe` returns a discriminated `ParseResult` (suitable for admin tooling that wants to render every Zod issue without try/catch). Both share the same underlying `pluginManifestSchema` so their accept/reject decisions are bit-for-bit identical.
## Features
- Single-pass safe parsing with all Zod issues surfaced
- Stable `MANIFEST.PARSE_FAILED` code on every failure
- Underlying Zod issues array preserved on `error.cause` and `error.context.issues`
- Idempotent — running parse on a previously-parsed manifest is a no-op
## Basic Example
```ts
import { parseManifest } from "@suaveplan/api-manifest/parse";
const manifest = parseManifest({ name: "x", version: "0.1.0", apiHost: "^1", isolation: "isolated", trustLevel: "first-party" });
```
## Advanced Example
```ts
import { parseManifestSafe } from "@suaveplan/api-manifest/parse";
const result = parseManifestSafe(unknownInput);
if (!result.ok) {
for (const issue of (result.error.cause as { issues: { path: (string|number)[]; message: string }[] }).issues) {
console.error(issue.path.join("."), issue.message);
}
}
```
## API Reference
- `parseManifest(input: unknown): PluginManifest` — throws `ManifestError` (`MANIFEST.PARSE_FAILED`).
- `parseManifestSafe(input: unknown): ParseResult` — returns `{ ok: true; data } | { ok: false; error }`.
- `ParseResult` — discriminated result type.
## Implementation Notes
Both functions go through `pluginManifestSchema.safeParse` so the strict-mode unknown-key rejection is enforced uniformly. The thrown/returned `ManifestError` carries the raw Zod issues array twice — once on `cause` (because `cause` is the canonical place to chain errors in modern Error) and once on `context.issues` (so the OTel span-event adapter picks it up automatically without needing a special `cause` walker).
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,51 @@
# `compatSchema`
## Purpose
Validates the compatibility block of a plugin manifest: the `apiHost` semver range and the two capability arrays (`requiredCapabilities`, `optionalCapabilities`). These three fields are the host's gate for deciding whether a plugin can boot against a given runtime version with a given capability matrix.
## Features
- `apiHost` is a non-empty string (semver-range syntax checked downstream)
- `requiredCapabilities` is an optional readonly string array
- `optionalCapabilities` is an optional readonly string array
- Inferred shape mirrors `api-types`' compat slice exactly
## Basic Example
```ts
import { compatSchema } from "@suaveplan/api-manifest/schema";
compatSchema.parse({
apiHost: "^1",
requiredCapabilities: ["db.transactions"],
});
```
## Advanced Example
```ts
import { compatSchema } from "@suaveplan/api-manifest/schema";
import { resolveCapabilities } from "@suaveplan/api-manifest/capability-resolver";
const compat = compatSchema.parse(rawCompat);
resolveCapabilities({
manifest: { ...manifestFields, ...compat },
registered,
});
```
## API Reference
- `compatSchema: z.ZodObject<{ apiHost, requiredCapabilities?, optionalCapabilities? }>`
- `CompatFields = z.infer<typeof compatSchema>`
## Implementation Notes
The `apiHost` field is intentionally a free-form string at the schema layer because the dep-graph's `satisfies()` accepts the same range syntax — moving the range validation into the schema would couple this module to the semver matcher. Capability names are checked for non-emptiness only; the host validates them against the live capability descriptor set via `resolveCapabilities`. Both arrays are typed as `readonly` to prevent accidental mutation by consumer code.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,44 @@
# `configSchema`
## Purpose
Plugin-level config block. `schemaPath` points at a JSON Schema the host validates user-supplied config against at install; `defaults` is a free-form record the host deep-merges on top of user-supplied config before validation runs.
## Features
- Optional `schemaPath` to a JSON Schema document
- Optional `defaults` as `Record<string, unknown>`
- Empty config block is valid (the plugin may need no config)
- Layered on top of the host's `@suaveplan/config` for final resolution
## Basic Example
```ts
import { configSchema } from "@suaveplan/api-manifest/schema";
configSchema.parse({ defaults: { trialDays: 14 } });
```
## Advanced Example
```ts
import { configSchema } from "@suaveplan/api-manifest/schema";
const cfg = configSchema.parse({
schemaPath: "config.schema.json",
defaults: { trialDays: 14, currency: "USD" },
});
```
## API Reference
- `configSchema: z.ZodObject<{ schemaPath?, defaults? }>`
- `ConfigBlock = z.infer<typeof configSchema>`
## Implementation Notes
The schema deliberately doesn't validate the contents of `defaults` because they're plugin-specific by definition; the JSON Schema at `schemaPath` is the canonical validator for the user-supplied config. Defaults are merged in by the host using a deep-merge strategy that favours user-supplied values; arrays are replaced rather than concatenated to keep the merge total.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,49 @@
# `databaseSchema`
## Purpose
Database-wiring block. Pins whether the plugin needs a database connection, which engines it supports, its current schema version, and how tenant isolation should be expressed. The host's migration runner reads `schemaVersion` + `migrationsDir` at install to apply forward-only migrations before plugin code runs.
## Features
- `required: false` lets a plugin opt out of database wiring entirely
- `preferredEngine` + `compatibleEngines` describe the engine compatibility matrix
- `schemaVersion` is a non-negative integer (0 = pre-migration)
- Three tenant-isolation modes: `row-level`, `schema`, `database`
## Basic Example
```ts
import { databaseSchema } from "@suaveplan/api-manifest/schema";
databaseSchema.parse({ required: true, schemaVersion: 0 });
```
## Advanced Example
```ts
import { databaseSchema } from "@suaveplan/api-manifest/schema";
const db = databaseSchema.parse({
required: true,
preferredEngine: "postgres",
compatibleEngines: ["postgres", "sqlite", "surrealdb"],
schemaVersion: 7,
migrationsDir: "migrations",
tenantIsolation: "row-level",
tablePrefix: "billing_",
});
```
## API Reference
- `databaseSchema: z.ZodObject<{ required, preferredEngine?, compatibleEngines?, schemaVersion, migrationsDir?, tenantIsolation?, tablePrefix? }>`
- `DatabaseBlock = z.infer<typeof databaseSchema>`
## Implementation Notes
Engine names are free-form strings rather than an enum so a plugin can declare compatibility with an engine the host doesn't know about yet — the capability resolver decides at boot whether any declared engine is available. `schemaVersion` is a single integer instead of a range; the host's migration runner walks every step from the journal's last-applied version up to this number. `tablePrefix` is a hint for engines that support it (Postgres/SQLite); SurrealDB uses table namespaces instead.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,47 @@
# `dependsOnSchema`
## Purpose
Map of plugin-name → semver range used by the dep-graph resolver. Names follow the same npm-package convention as `identity.name`; ranges follow the same syntax the in-house `satisfies()` accepts (pinned, caret, tilde, wildcard, `*`/`latest`).
## Features
- Free-form record keys (plugin names) — keyspace is checked downstream by the dep-graph against the actual install set
- Range values are non-empty strings
- Readonly map output discourages mutation
- Empty map (`{}`) parses successfully — a plugin with no dependencies is a leaf
## Basic Example
```ts
import { dependsOnSchema } from "@suaveplan/api-manifest/schema";
dependsOnSchema.parse({ "@suaveplan/api-plugin-users": "^1" });
```
## Advanced Example
```ts
import { resolveInstallOrder } from "@suaveplan/api-manifest/dep-graph";
import { dependsOnSchema } from "@suaveplan/api-manifest/schema";
const deps = dependsOnSchema.parse(raw);
const order = resolveInstallOrder([
{ ...userManifest, dependsOn: deps },
...others,
]);
```
## API Reference
- `dependsOnSchema: z.ZodReadonly<z.ZodRecord<z.ZodString, z.ZodString>>`
- `DependsOn = z.infer<typeof dependsOnSchema>`
## Implementation Notes
The schema deliberately doesn't pre-validate range syntax — `resolveInstallOrder` round-trips every range through `satisfies()` and surfaces `MANIFEST.DEPENDENCY_VERSION_MISMATCH` on anything it can't parse, so the error envelope is identical for invalid ranges and unsatisfied ranges. This keeps the schema decoupled from the matcher's exact range subset. Duplicate keys in JSON resolve to last-wins per `JSON.parse`, and the dep-graph treats each entry independently.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,48 @@
# `errorsSchema`
## Purpose
Plugin-error status-map block. Maps `PLUGIN.<DOMAIN>.<REASON>` codes to HTTP status numbers and optional redaction paths. The host's error middleware consults this map when rendering error envelopes so plugins get a stable wire shape across boot, reload, and version upgrades.
## Features
- Code regex enforces `PLUGIN.<UPPER>.<UPPER>` template
- Status codes restricted to 4xx and 5xx
- Redact entries are dot-paths into the error's context payload
- Defence-in-depth re-check at the validator layer surfaces every offender in one pass
## Basic Example
```ts
import { errorsSchema } from "@suaveplan/api-manifest/schema";
errorsSchema.parse({ statusMap: { "PLUGIN.X.Y": 409 } });
```
## Advanced Example
```ts
import { errorsSchema } from "@suaveplan/api-manifest/schema";
const errs = errorsSchema.parse({
statusMap: {
"PLUGIN.BILLING.SUBSCRIPTION_INACTIVE": 409,
"PLUGIN.BILLING.STRIPE_API_FAILURE": 502,
},
redact: { "PLUGIN.BILLING.STRIPE_API_FAILURE": ["details.stripeKey"] },
});
```
## API Reference
- `errorsSchema: z.ZodObject<{ statusMap?, redact? }>`
- `errorCodeRegex`, `errorPathSegmentRegex` — exported for cross-validator reuse.
- `Errors = z.infer<typeof errorsSchema>`
## Implementation Notes
`errorCodeRegex` is exported because the cross-field validator `validateErrorStatusMapShape` needs it too — splitting the regex would risk drift. Status numbers are validated to be integers in [400, 600) so the host can be sure it's safe to set them on the response status line. Redact paths use dot-separated identifiers; each segment is validated to be a valid JS identifier so nested-key lookups don't need to handle quoting.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,44 @@
# `eventsSchema`
## Purpose
Event-bus topology declaration. Lists the subjects this plugin publishes and the subjects it subscribes to. Subjects follow the `domain.entity.verb` convention which the regex enforces — the host's bus cross-checks publish/subscribe pairs at boot for typo + drift surfacing.
## Features
- Topic regex enforces lower-snake_case dotted segments, ≥2 segments
- Optional `publishes` / `subscribes` readonly string arrays
- Empty event blocks are allowed (a plugin may neither publish nor subscribe)
- Cross-plugin pairing surface for the host's bus introspector
## Basic Example
```ts
import { eventsSchema } from "@suaveplan/api-manifest/schema";
eventsSchema.parse({ publishes: ["billing.invoice.created"] });
```
## Advanced Example
```ts
import { eventsSchema } from "@suaveplan/api-manifest/schema";
const events = eventsSchema.parse({
publishes: ["billing.invoice.created", "billing.payment.succeeded"],
subscribes: ["users.user.deleted"],
});
```
## API Reference
- `eventsSchema: z.ZodObject<{ publishes?, subscribes? }>`
- `Events = z.infer<typeof eventsSchema>`
## Implementation Notes
The topic regex (`^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$`) enforces at least one dot — single-segment topics are reserved for the bus's internal channels. Lower-case letters, digits, hyphens, and underscores are accepted inside segments; mixed-case is rejected to match the rest of the bus contract. Cross-plugin checking (does anything subscribe to what we publish?) lives in the host, not in this schema.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,45 @@
# `graphqlSchema`
## Purpose
Optional GraphQL contribution block. When `contributes` is `true`, the host loads the SDL at `types` (path relative to the package root) and merges the resolvers exported from `resolversFrom` into the federated schema. The cross-field validator `validateOpenApiRequiresGraphqlOrRoutes` references this flag.
## Features
- `contributes` boolean discriminator
- Optional `types` path to an SDL file
- Optional `resolversFrom` path to a JS/TS module exporting `resolvers`
- Composable with `routes` and `openapi` blocks
## Basic Example
```ts
import { graphqlSchema } from "@suaveplan/api-manifest/schema";
graphqlSchema.parse({ contributes: false });
```
## Advanced Example
```ts
import { graphqlSchema } from "@suaveplan/api-manifest/schema";
const gql = graphqlSchema.parse({
contributes: true,
types: "graphql/types.graphql",
resolversFrom: "src/graphql.ts",
});
```
## API Reference
- `graphqlSchema: z.ZodObject<{ contributes, types?, resolversFrom? }>`
- `GraphqlBlock = z.infer<typeof graphqlSchema>`
## Implementation Notes
`contributes: false` is a legal state — it lets a plugin opt out of GraphQL while keeping the block present for tooling that wants a uniform shape. Paths are intentionally strings (not URLs or `node:fs`-resolved paths) because the host resolves them relative to the package root after install; baking resolution into the schema would couple it to a specific module-resolution strategy.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,54 @@
# `identitySchema`
## Purpose
Zod schema for the identity fields of a plugin manifest — the keys that uniquely identify a plugin in the registry and pin its semver. Validates `name` against npm package-name rules and `version` against strict SemVer 2.0.0. Optional `$schema`, `displayName`, `description`, `license` fields round out the metadata block.
## Features
- NPM-compatible name regex (`@scope/pkg` optional, lower-case, hyphen/underscore allowed)
- Strict SemVer 2.0.0 validation including pre-release and build metadata
- Optional `$schema` URL for JSON-Schema-aware editors
- Optional `displayName`, `description`, `license` carry over to the admin UI
## Basic Example
```ts
import { identitySchema } from "@suaveplan/api-manifest/schema";
identitySchema.parse({
name: "@acme/billing-plugin",
version: "1.4.2",
});
```
## Advanced Example
```ts
import { identitySchema } from "@suaveplan/api-manifest/schema";
const result = identitySchema.safeParse({
name: "acme/billing",
version: "1.4",
});
if (!result.success) {
for (const issue of result.error.issues) {
console.error(issue.path, issue.message);
}
}
```
## API Reference
- `identitySchema: z.ZodObject<{ $schema?, name, version, displayName?, description?, license? }>`
- `IdentityFields = z.infer<typeof identitySchema>`
## Implementation Notes
Name validation matches the npm registry's regex (lower-case, no leading dot/underscore, optional `@scope/` prefix); 214-char max length matches npm's hard limit. Version regex implements SemVer 2.0.0 exactly — `MAJOR.MINOR.PATCH[-PRE][+BUILD]` — which is what `satisfies()` in the dep-graph module assumes; loose versions like `1.4` are rejected to keep the dep-graph comparator total. The identity block is merged into `pluginManifestSchema` rather than nested so the top-level keys match the JSON wire shape from design §3.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,46 @@
# `isolationSchema` + `trustLevelSchema`
## Purpose
Two Zod enum schemas covering the orthogonal isolation and trust axes of the manifest. Isolation determines how the host loads the plugin (in-process, worker-isolated, or sandboxed-with-permissions); trust level signals provenance independent of how isolation is enforced.
## Features
- Three discrete isolation tiers: `trusted`, `isolated`, `sandboxed`
- Three trust levels: `first-party`, `third-party`, `untrusted`
- Both inferred types are string-literal unions (no widening)
- Independent axes so a `trusted`+`third-party` combo is representable when an operator opts in
## Basic Example
```ts
import { isolationSchema, trustLevelSchema } from "@suaveplan/api-manifest/schema";
isolationSchema.parse("sandboxed");
trustLevelSchema.parse("first-party");
```
## Advanced Example
```ts
import { isolationSchema, type Isolation } from "@suaveplan/api-manifest/schema";
function needsBudgets(tier: Isolation): boolean {
return isolationSchema.parse(tier) === "sandboxed";
}
```
## API Reference
- `isolationSchema: z.ZodEnum<["trusted", "isolated", "sandboxed"]>`
- `trustLevelSchema: z.ZodEnum<["first-party", "third-party", "untrusted"]>`
- `Isolation`, `TrustLevel` — inferred type aliases.
## Implementation Notes
Trust level and isolation tier are intentionally orthogonal — a first-party plugin may still be run sandboxed for defence-in-depth, and a third-party plugin may run trusted when an operator has audited and signed it. The validator `validatePermissionsWhenSandboxed` couples isolation to the permissions block at the cross-field layer rather than the Zod schema. Both enums are closed unions — adding a new tier requires an spec change because every downstream consumer's discriminated union widens.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,47 @@
# `jobsSchema`
## Purpose
Background-job block: cron schedules and queued workers. Handlers live in the code half of the manifest; this block is the declarative surface the host pre-registers so cron triggers and queue topics exist before plugin code runs.
## Features
- Cron expression validated against a permissive character regex
- Optional timezone per cron entry (host applies a default if absent)
- Optional `perTenant` flag for tenant-scoped cron
- Queued jobs carry an optional `maxAttempts` retry cap
## Basic Example
```ts
import { jobsSchema } from "@suaveplan/api-manifest/schema";
jobsSchema.parse({ cron: [{ name: "daily", expression: "0 0 * * *" }] });
```
## Advanced Example
```ts
import { jobsSchema } from "@suaveplan/api-manifest/schema";
const jobs = jobsSchema.parse({
cron: [
{ name: "reconcile", expression: "0 3 * * *", tz: "UTC", perTenant: true },
],
queued: [{ name: "send-receipt", maxAttempts: 5 }],
});
```
## API Reference
- `jobsSchema: z.ZodObject<{ cron?, queued? }>`
- `cronJobSchema`, `queuedJobSchema` — the building blocks.
- Types: `Jobs`, `CronJob`, `QueuedJob`.
## Implementation Notes
The cron expression regex permits the digits, asterisks, commas, slashes, and dashes that cover both 5-field and 6-field crontabs; the host's `@suaveplan/cron` parser is the source of truth for actual scheduling semantics, including L/W/# extensions. The schema's regex is a fast-fail surface, not the canonical parser. `perTenant: true` instructs the host's tenant fan-out to run one logical cron per tenant rather than a single host-wide cron.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,45 @@
# `openapiSchema`
## Purpose
OpenAPI contribution block. When `contributes: true` the host emits a per-plugin OpenAPI document by introspecting the registered routes (and, if `graphql.contributes` is also true, the GraphQL schema). The cross-field validator `validateOpenApiRequiresGraphqlOrRoutes` enforces that there is actually something to document.
## Features
- `contributes` boolean discriminator
- Optional `tagDisplayName` for the rendered docs UI
- Optional `externalDocs` URL
- Cross-field coupling to `routes`/`graphql` enforced by a separate validator
## Basic Example
```ts
import { openapiSchema } from "@suaveplan/api-manifest/schema";
openapiSchema.parse({ contributes: false });
```
## Advanced Example
```ts
import { openapiSchema } from "@suaveplan/api-manifest/schema";
const api = openapiSchema.parse({
contributes: true,
tagDisplayName: "Billing & Subscriptions",
externalDocs: "https://docs.acme.io/billing",
});
```
## API Reference
- `openapiSchema: z.ZodObject<{ contributes, tagDisplayName?, externalDocs? }>`
- `OpenApiBlock = z.infer<typeof openapiSchema>`
## Implementation Notes
The `externalDocs` URL is checked with Zod's built-in URL validator, which rejects schemes other than http/https; that matches the OpenAPI 3.1 spec's expectations. `tagDisplayName` is rendered by the docs UI as the grouping label for this plugin's routes. The block is intentionally minimal — the host's OpenAPI emitter owns the actual schema generation.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,48 @@
# `permissionsSchema` + `budgetsSchema`
## Purpose
Zod schemas for the sandbox permissions block. `permissions.fs` and `permissions.fetch` are host-proxied allowlists honoured only when isolation is `sandboxed`; `permissions.budgets` carries CPU/memory/wall-clock caps for the per-request quota enforcer.
## Features
- Optional `fs` and `fetch` allowlists (readonly string arrays)
- `budgets.cpuPercent` clamped to 1..100
- `budgets.memoryMb` and `budgets.wallClockMs` positive integers
- Cross-field rule `validatePermissionsWhenSandboxed` enforces budgets-when-sandboxed at a separate layer
## Basic Example
```ts
import { permissionsSchema } from "@suaveplan/api-manifest/schema";
permissionsSchema.parse({ budgets: { cpuPercent: 25, memoryMb: 512 } });
```
## Advanced Example
```ts
import { permissionsSchema, budgetsSchema } from "@suaveplan/api-manifest/schema";
const budgets = budgetsSchema.parse({ cpuPercent: 25, memoryMb: 512, wallClockMs: 30000 });
const perms = permissionsSchema.parse({
fs: ["./uploads/*"],
fetch: ["api.stripe.com"],
budgets,
});
```
## API Reference
- `permissionsSchema: z.ZodObject<{ fs?, fetch?, budgets? }>`
- `budgetsSchema: z.ZodObject<{ cpuPercent?, memoryMb?, wallClockMs? }>`
- `Permissions`, `Budgets` — inferred type aliases.
## Implementation Notes
The `fs` and `fetch` allowlists use plain strings rather than parsed globs/URLs to keep the schema decoupled from any specific matcher (`micromatch`, `globby`, etc.); the host plugin that enforces the sandbox chooses its own matcher. `cpuPercent` is capped at 100 so a misconfigured plugin can't request more than a full core. Every field is optional at the schema level; the `sandboxed`-tier requirement of having budgets at all is enforced at the cross-field validator layer.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,45 @@
# `pluginManifestSchema` and `PluginManifest`
## Purpose
Composite Zod schema joining every per-key sub-schema into a single strict-mode object. The inferred `PluginManifest` type is structurally assignable to the `api-types` view of the same shape — `AssignableToApiTypes` is a compile-time fence that the type-system enforces during typecheck.
## Features
- Strict mode at the top level — unknown keys are rejected
- Every documented field from design §3 is covered
- Type-level fence to `@suaveplan/api-types/manifest` `PluginManifest`
- Single import point for the entire manifest contract
## Basic Example
```ts
import { pluginManifestSchema } from "@suaveplan/api-manifest/schema";
pluginManifestSchema.parse({ name: "x", version: "0.1.0", apiHost: "^1", isolation: "isolated", trustLevel: "first-party" });
```
## Advanced Example
```ts
import { pluginManifestSchema, type PluginManifest, type AssignableToApiTypes } from "@suaveplan/api-manifest/schema";
import type { PluginManifest as ApiTypesPluginManifest } from "@suaveplan/api-types/manifest";
const _fence: AssignableToApiTypes = true;
function asApiTypes(m: PluginManifest): ApiTypesPluginManifest { return m; }
```
## API Reference
- `pluginManifestSchema: z.ZodObject<...>.strict()`
- `PluginManifest = z.infer<typeof pluginManifestSchema>`
- `AssignableToApiTypes` — compile-time fence type.
## Implementation Notes
Strict mode at the top level is the host's drift-detection surface: any unknown top-level key (typoed `displayname` instead of `displayName`, or a future v2 field) is rejected with a `MANIFEST.PARSE_FAILED` error pointing at the offending path. Sub-objects use the Zod default (`.passthrough()` not applied, but no `.strict()` either) so future additive expansion inside a block remains possible without breaking existing consumers.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,44 @@
# `routesSchema`
## Purpose
Validates the declarative HTTP-routes block. Carries the route `prefix` plus an optional `publicRoutes` array — routes the host mounts without auth gating. The handler implementations live in the code half of the manifest (`definePlugin({ routes })`); this block only captures what the host needs to know declaratively to mount + introspect routes before loading code.
## Features
- `prefix` must start with `/` — Zod regex rejects unprefixed segments
- `publicRoutes` entries must each start with `/`
- Public routes are optional — most plugins authenticate every route
- Inferred type is the same shape `api-types` exposes via `RouteManifest`
## Basic Example
```ts
import { routesSchema } from "@suaveplan/api-manifest/schema";
routesSchema.parse({ prefix: "/billing" });
```
## Advanced Example
```ts
import { routesSchema } from "@suaveplan/api-manifest/schema";
const routes = routesSchema.parse({
prefix: "/billing",
publicRoutes: ["/webhooks/stripe", "/health"],
});
```
## API Reference
- `routesSchema: z.ZodObject<{ prefix, publicRoutes? }>`
- `Routes = z.infer<typeof routesSchema>`
## Implementation Notes
Public-route paths are matched verbatim by the host's auth middleware; supporting glob patterns is intentionally deferred — the right place for that is the rate-limit / auth plugin, not the manifest. The prefix lives in the manifest rather than the code half so admin tooling can build the canonical URL map without loading any plugin code, which matters for sandboxed plugins where loading code is expensive.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,51 @@
# `sagasSchema`
## Purpose
Saga ownership + participation block. `owns` lists the sagas this plugin orchestrates (each with a non-empty step list); `participatesIn` lists the sagas this plugin only contributes a step to. The cross-field validator `validateSagaOwnership` couples saga steps to `dependsOn` so a step's `plugin` reference must be either the manifest's own name or a declared dependency.
## Features
- Each owned saga has a name plus ≥1 steps
- Each step pins a `plugin`, `action`, and `compensate` handler name
- `participatesIn` lists the saga IDs this plugin contributes a step to
- Cross-plugin coupling enforced at the validator layer, not the schema
## Basic Example
```ts
import { sagasSchema } from "@suaveplan/api-manifest/schema";
sagasSchema.parse({ owns: [], participatesIn: ["@acme/onboarding/setup"] });
```
## Advanced Example
```ts
import { sagasSchema } from "@suaveplan/api-manifest/schema";
const sagas = sagasSchema.parse({
owns: [
{
name: "create-customer",
steps: [
{ plugin: "@acme/billing", action: "createSub", compensate: "cancelSub" },
],
},
],
});
```
## API Reference
- `sagasSchema: z.ZodObject<{ owns?, participatesIn? }>`
- `sagaStepSchema`, `ownedSagaSchema` — the building blocks.
- Types: `Sagas`, `OwnedSaga`, `SagaStep`.
## Implementation Notes
An owned saga MUST have at least one step (`min(1)`); a saga with zero steps is a degenerate state the host can't run, so we reject it at parse time rather than at boot. The step shape pins three handler names; the host resolves each handler name against the named plugin's exported saga-handler map at start. `participatesIn` is a string array of fully-qualified saga IDs (`<owner-plugin>/<saga-name>`) to avoid name collisions across plugins.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,46 @@
# `scopesSchema`
## Purpose
AuthZ scope declarations. Each entry pins a `resource:action` name and an optional human-readable description. The host's permission system merges every plugin's scope list and surfaces the union to admin tooling for role-binding.
## Features
- `resource:action` regex (lower-case, hyphen/underscore allowed, exactly one colon)
- Optional `description` for the admin UI
- Readonly array discourages mutation by caller code
- Cross-plugin merging happens at the host layer
## Basic Example
```ts
import { scopesSchema } from "@suaveplan/api-manifest/schema";
scopesSchema.parse([{ name: "billing:read" }]);
```
## Advanced Example
```ts
import { scopesSchema } from "@suaveplan/api-manifest/schema";
const scopes = scopesSchema.parse([
{ name: "billing:read", description: "Read invoices and subscriptions" },
{ name: "billing:write", description: "Create/cancel subscriptions" },
{ name: "billing:admin", description: "Refunds and manual overrides" },
]);
```
## API Reference
- `scopeSchema: z.ZodObject<{ name, description? }>`
- `scopesSchema: z.ZodReadonly<z.ZodArray<typeof scopeSchema>>`
- `Scope`, `Scopes`.
## Implementation Notes
The regex enforces exactly one colon — wildcards (`billing:*`) are intentionally not allowed in declarations because they make role-binding ambiguous; the host's permission system supports wildcards in role grants instead. Descriptions are free-form strings; rendering is the admin UI's job. Duplicate names across plugins are handled at the host's merge layer.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,46 @@
# `secretsSchema`
## Purpose
Declares the named secrets the plugin needs at boot. Names use SCREAMING_SNAKE_CASE so they map cleanly onto environment-variable conventions; the `required: true` flag tells the host to fail fast at boot when the value is missing rather than at first use.
## Features
- Names matched against `/^[A-Z][A-Z0-9_]*$/`
- `required` is a non-optional boolean (no implicit default)
- Readonly array of secret descriptors
- Source-of-truth for the host's secret-store adapter at boot
## Basic Example
```ts
import { secretsSchema } from "@suaveplan/api-manifest/schema";
secretsSchema.parse([{ name: "STRIPE_SECRET_KEY", required: true }]);
```
## Advanced Example
```ts
import { secretsSchema } from "@suaveplan/api-manifest/schema";
const secrets = secretsSchema.parse([
{ name: "STRIPE_SECRET_KEY", required: true },
{ name: "STRIPE_WEBHOOK_SECRET", required: true },
{ name: "STRIPE_PUBLISHABLE_KEY", required: false },
]);
```
## API Reference
- `secretSchema: z.ZodObject<{ name, required }>`
- `secretsSchema: z.ZodReadonly<z.ZodArray<typeof secretSchema>>`
- `Secret`, `Secrets`.
## Implementation Notes
Secret values never appear in the manifest — only the names do. The host's secret-store adapter resolves each name against its backing source (env, Bun.secrets, Vault, etc.) at boot. Making `required` non-optional is deliberate: a plugin author who has thought hard enough to declare a secret has thought hard enough to say whether the plugin can boot without it. Defaults at the schema layer would mask drift.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,44 @@
# `sseEndpointSchema` + `sseSchema`
## Purpose
Server-Sent-Events endpoint declarations. Mirrors the WebSocket block's shape minus the heartbeat/connection cap — SSE is half-duplex and the long-poll-style connection cost is lower so per-endpoint sizing isn't required at the manifest layer.
## Features
- Path must start with `/`
- Optional `auth` scope
- Optional subscription-subject list
- Composable with `websockets` — a plugin can ship both
## Basic Example
```ts
import { sseEndpointSchema } from "@suaveplan/api-manifest/schema";
sseEndpointSchema.parse({ path: "/feed" });
```
## Advanced Example
```ts
import { sseSchema } from "@suaveplan/api-manifest/schema";
const feeds = sseSchema.parse([
{ path: "/feed", auth: "feed:read", subscriptions: ["feed.item"] },
]);
```
## API Reference
- `sseEndpointSchema: z.ZodObject<{ path, auth?, subscriptions? }>`
- `sseSchema: z.ZodReadonly<z.ZodArray<typeof sseEndpointSchema>>`
- `SseEndpoint`, `Sse`.
## Implementation Notes
SSE is intentionally a separate block from WebSockets even though the host could synthesise one from the other — a plugin author typically picks the transport based on browser-support and proxy-traversal trade-offs, so making the choice explicit in the manifest makes the contract auditable. The host can still elect to emulate one over the other when capabilities are missing.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,47 @@
# `telemetrySchema`
## Purpose
Telemetry-instrument declarations. The host pre-registers every named counter, histogram, and gauge so plugin code can emit observations without first round-tripping through a registration call. The optional `metricPrefix` and `spanPrefix` keep cross-plugin observability namespaces clean.
## Features
- Optional `metricPrefix` and `spanPrefix` strings
- `counters`, `histograms`, `gauges` are each optional readonly arrays of `{ name, labels? }`
- Labels are optional per-instrument (cardinality control is the operator's job)
- Instrument names are free-form strings — the schema doesn't enforce a dotted-segment convention
## Basic Example
```ts
import { telemetrySchema } from "@suaveplan/api-manifest/schema";
telemetrySchema.parse({ counters: [{ name: "requests_total" }] });
```
## Advanced Example
```ts
import { telemetrySchema } from "@suaveplan/api-manifest/schema";
const telem = telemetrySchema.parse({
metricPrefix: "billing.",
spanPrefix: "billing.",
counters: [{ name: "billing.invoices_finalised_total", labels: ["currency"] }],
histograms: [{ name: "billing.stripe.call_duration_ms", labels: ["operation"] }],
gauges: [{ name: "billing.outstanding_invoice_count" }],
});
```
## API Reference
- `telemetrySchema: z.ZodObject<{ metricPrefix?, spanPrefix?, counters?, histograms?, gauges? }>`
- `Telemetry = z.infer<typeof telemetrySchema>`
## Implementation Notes
Pre-registering instruments at boot is how the host avoids the registration-on-first-emit problem (race conditions across worker boundaries; cardinality explosions from a typo). Instrument names are validated for non-emptiness only; the actual prefix-merge convention is enforced by the host's OTel adapter rather than the schema.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,44 @@
# `twoPhaseCommitSchema`
## Purpose
Two-phase-commit participation declaration. A plugin sets `participates: true` to opt into the host's 2PC coordinator; the host's capability checker verifies the `db.preparable` capability is present at boot before any 2PC transaction is attempted.
## Features
- Single-field block — boolean discriminator
- Coupled to the `db.preparable` capability at the host layer
- Plugins that don't need 2PC simply omit the block
- Designed for future expansion (timeout overrides, isolation hints)
## Basic Example
```ts
import { twoPhaseCommitSchema } from "@suaveplan/api-manifest/schema";
twoPhaseCommitSchema.parse({ participates: true });
```
## Advanced Example
```ts
import { twoPhaseCommitSchema, pluginManifestSchema } from "@suaveplan/api-manifest/schema";
const manifest = pluginManifestSchema.parse(raw);
if (manifest.twoPhaseCommit?.participates) {
// require db.preparable capability
}
```
## API Reference
- `twoPhaseCommitSchema: z.ZodObject<{ participates }>`
- `TwoPhaseCommit = z.infer<typeof twoPhaseCommitSchema>`
## Implementation Notes
The shape is intentionally minimal at v1 — the host owns the timeout, retry, and recovery policy globally, so the manifest only needs to declare opt-in. Future versions can extend the schema with per-plugin timeout overrides under the same key; `.strict()` on the composite manifest catches accidental misnamed fields ahead of any v2 migration.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,44 @@
# `uninstallSchema`
## Purpose
Uninstall data-handling policy. Three modes: `archive` (move rows to a prefixed shadow table so they survive removal), `drop` (destroy the plugin's schema), `keep` (leave data in place for a later reinstall). The optional `archiveTablePrefix` lets a plugin pin the shadow-table prefix the host uses.
## Features
- Closed enum of three policies
- Optional `archiveTablePrefix` (host falls back to a default when absent)
- Required at the manifest level only when uninstall semantics matter to the plugin
- Designed for forward expansion (retention windows, audit-log emission)
## Basic Example
```ts
import { uninstallSchema } from "@suaveplan/api-manifest/schema";
uninstallSchema.parse({ dataPolicy: "keep" });
```
## Advanced Example
```ts
import { uninstallSchema } from "@suaveplan/api-manifest/schema";
const uninstall = uninstallSchema.parse({
dataPolicy: "archive",
archiveTablePrefix: "_archived_billing_",
});
```
## API Reference
- `uninstallSchema: z.ZodObject<{ dataPolicy, archiveTablePrefix? }>`
- `Uninstall = z.infer<typeof uninstallSchema>`
## Implementation Notes
`archive` is the default policy most plugins should pick — it preserves the plugin's data behind a prefix so the host's admin tool can resurrect it during a re-install. `drop` is irreversible and the host emits a destructive-action audit-log entry whenever it's selected. `keep` leaves data in place for plugins where ownership is unclear (e.g. shared tables across plugin versions); the host emits a warning in this case.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,44 @@
# `websocketSchema` + `websocketsSchema`
## Purpose
Validates the WebSocket endpoint declarations. Each entry pins a path, an optional auth scope, the set of subjects the connection can subscribe to, and the per-tenant connection cap plus heartbeat interval the host should enforce.
## Features
- Path must start with `/`
- Optional `auth` scope (resource:action format applied at manifest-merge time)
- Subscriptions are an optional readonly subject array
- Positive-integer `maxConnectionsPerTenant` and `heartbeatMs`
## Basic Example
```ts
import { websocketSchema } from "@suaveplan/api-manifest/schema";
websocketSchema.parse({ path: "/live" });
```
## Advanced Example
```ts
import { websocketsSchema } from "@suaveplan/api-manifest/schema";
const sockets = websocketsSchema.parse([
{ path: "/live/orders", auth: "orders:read", maxConnectionsPerTenant: 100, heartbeatMs: 30000 },
]);
```
## API Reference
- `websocketSchema: z.ZodObject<{ path, auth?, subscriptions?, maxConnectionsPerTenant?, heartbeatMs? }>`
- `websocketsSchema: z.ZodReadonly<z.ZodArray<typeof websocketSchema>>`
- `Websocket`, `Websockets`.
## Implementation Notes
Per-tenant connection caps live in the manifest (rather than the host's global config) because they're a plugin-specific contract — a chat plugin needs a higher cap than a billing dashboard plugin. The heartbeat interval is in milliseconds rather than seconds to match the rest of the host's timing surface; values below ~5000 are technically legal but typically only used in tests.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,54 @@
# `runManifestConformance` and fixtures
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Reusable conformance harness for downstream tooling (admin CLI, IDE plugin) that ships its own `parseManifest` implementation. The harness drives any parser through four fixture buckets — minimal-valid, maximal-valid, missing-name-invalid, unknown-key-invalid — and returns a per-bucket boolean report.
## Features
- Two valid fixtures: minimal (the smallest legal manifest) and maximal (every documented field)
- Two invalid fixtures: missing-name and unknown-key (the strict-mode rejection case)
- `runManifestConformance` returns a discriminated `ConformanceReport`
- Fixtures exported individually so tests can build variants without copy/paste
## Basic Example
```ts
import { runManifestConformance } from "@suaveplan/api-manifest/testing";
import { parseManifest } from "@suaveplan/api-manifest/parse";
const report = runManifestConformance(parseManifest);
```
## Advanced Example
```ts
import { runManifestConformance, MAXIMAL_FIXTURE, type ConformanceFixtures } from "@suaveplan/api-manifest/testing";
const fixtures: ConformanceFixtures = {
minimal: { name: "x", version: "0.1.0", apiHost: "^2", isolation: "trusted", trustLevel: "first-party" },
maximal: MAXIMAL_FIXTURE,
invalidMissingName: {},
invalidUnknownKey: { ...MAXIMAL_FIXTURE, badKey: 1 },
};
const report = runManifestConformance(parseManifest, fixtures);
```
## API Reference
- `runManifestConformance(parse, fixtures?): ConformanceReport`
- `MINIMAL_FIXTURE`, `MAXIMAL_FIXTURE`, `DEFAULT_FIXTURES`.
- `ConformanceFixtures`, `ConformanceReport`.
## Implementation Notes
The harness deliberately returns a structured report rather than throwing — downstream consumers may want to render the report into their own CI surface (a custom matcher, a JUnit emitter, etc.) without coupling to a specific assertion library. The `MAXIMAL_FIXTURE` is the same shape used in the package's own integration tests so any downstream parser that passes the harness is checked against the same canonical example.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,49 @@
# `validateErrorStatusMapShape`
## Purpose
Defence-in-depth re-check of the `errors.statusMap` shape. The Zod schema already rejects malformed entries; this validator collects every offender (rather than fail-fast on the first) so admin tooling can render the full diagnostic in one pass — operators get a complete fix list instead of fix-one-rerun-find-next.
## Features
- Walks every map entry — no early exit
- Surfaces code-format violations separately from status-range violations
- Empty / absent map is a no-op success
- Reuses `errorCodeRegex` from the schema layer to avoid drift
## Basic Example
```ts
import { validateErrorStatusMapShape, parseManifest } from "@suaveplan/api-manifest";
validateErrorStatusMapShape(parseManifest(raw));
```
## Advanced Example
```ts
import { validateErrorStatusMapShape } from "@suaveplan/api-manifest/validators";
import { parseManifest } from "@suaveplan/api-manifest/parse";
const manifest = parseManifest(raw);
const check = validateErrorStatusMapShape(manifest);
if (!check.ok) {
for (const o of (check.error.context?.offenders as { code: string; status: number; reason: string }[])) {
console.error(o.code, o.status, o.reason);
}
}
```
## API Reference
- `validateErrorStatusMapShape(manifest: PluginManifest): ValidationResult`
- Failure code: `MANIFEST.INVALID_ERROR_STATUS_MAP` with `context.offenders: { code, status, reason }[]`.
## Implementation Notes
Because the Zod schema rejects ill-shaped entries up front, the only way this validator ever finds an offender is via direct programmatic construction (skipping `parseManifest`) — but that path is real (the host builds plugin manifests in tests, the registry seeds them, etc.). The validator's batch-mode contract means it remains the canonical check at the install gate even when the schema has already run.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,46 @@
# `validateOpenApiRequiresGraphqlOrRoutes`
## Purpose
Rejects manifests that declare an OpenAPI contribution but expose neither HTTP routes nor a GraphQL contribution. The OpenAPI emitter needs a surface to document; without one the emission step would produce an empty document that wastes admin-UI real estate.
## Features
- No-op success when `openapi.contributes` is false or absent
- Accepts either `routes` (any prefix) or `graphql.contributes === true`
- Stable `MANIFEST.OPENAPI_REQUIRES_GRAPHQL_OR_ROUTES` code
- Manifest name surfaced in the error context
## Basic Example
```ts
import { validateOpenApiRequiresGraphqlOrRoutes, parseManifest } from "@suaveplan/api-manifest";
const r = validateOpenApiRequiresGraphqlOrRoutes(parseManifest(raw));
if (!r.ok) throw r.error;
```
## Advanced Example
```ts
import { validateOpenApiRequiresGraphqlOrRoutes } from "@suaveplan/api-manifest/validators";
import { parseManifest } from "@suaveplan/api-manifest/parse";
const manifest = parseManifest(raw);
const check = validateOpenApiRequiresGraphqlOrRoutes(manifest);
if (!check.ok) console.warn("OpenAPI contrib will be empty:", manifest.name);
```
## API Reference
- `validateOpenApiRequiresGraphqlOrRoutes(manifest: PluginManifest): ValidationResult`
- Failure code: `MANIFEST.OPENAPI_REQUIRES_GRAPHQL_OR_ROUTES`.
## Implementation Notes
GraphQL contribution is detected by `graphql.contributes === true` rather than mere presence of the block; a `{ contributes: false }` block is a legal opt-out state. Routes contribution is detected by mere presence — any prefix counts because the host will introspect handlers at boot. The check is intentionally cheap: a single boolean expression over four readily-available fields.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,48 @@
# `validatePermissionsWhenSandboxed`
## Purpose
Enforces that a `sandboxed`-tier plugin declares `permissions.budgets`. Without budgets the host can't enforce CPU/memory/wall-clock quotas, and the sandbox guarantees would be a stronger claim than the runtime actually delivers. This is the canonical case where Zod alone can't capture the invariant — the requirement spans two top-level keys.
## Features
- No-op success for non-sandboxed isolation tiers
- Single-shot validation — fails iff isolation is sandboxed and budgets is missing
- Stable `MANIFEST.SANDBOX_REQUIRES_BUDGETS` code
- Manifest name surfaced in the error context for admin tooling
## Basic Example
```ts
import { validatePermissionsWhenSandboxed, parseManifest } from "@suaveplan/api-manifest";
const r = validatePermissionsWhenSandboxed(parseManifest(raw));
if (!r.ok) throw r.error;
```
## Advanced Example
```ts
import { validatePermissionsWhenSandboxed, type ValidationResult } from "@suaveplan/api-manifest/validators";
import { parseManifest } from "@suaveplan/api-manifest/parse";
function install(raw: unknown): void {
const manifest = parseManifest(raw);
const checks: ValidationResult[] = [validatePermissionsWhenSandboxed(manifest)];
for (const c of checks) if (!c.ok) throw c.error;
}
```
## API Reference
- `validatePermissionsWhenSandboxed(manifest: PluginManifest): ValidationResult`
- Failure code: `MANIFEST.SANDBOX_REQUIRES_BUDGETS` with `context.name`.
## Implementation Notes
`permissions.budgets` is the host's hook for the quota enforcer — values can be partial (only `cpuPercent` set, no `memoryMb`) and the enforcer applies host defaults to missing fields. The validator only requires the block to be present, not for every sub-field to be populated; that lets a plugin opt into CPU caps without committing to a specific memory cap. The non-sandboxed-tier no-op return is important for the install gate's batch-validation path.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.

View File

@@ -0,0 +1,46 @@
# `validateSagaOwnership`
## Purpose
Cross-field validator that rejects sagas whose step list references a plugin neither owned by this manifest nor declared as a dependency. Catches typos and missing `dependsOn` entries at parse time rather than at runtime saga dispatch, which is exactly the kind of drift that's expensive to debug in production.
## Features
- Returns `{ ok: false }` with the full list of offending step references
- Treats the manifest's own `name` as a self-reference
- Walks every owned saga's step list, not just the first
- Empty `sagas.owns` is a no-op success
## Basic Example
```ts
import { validateSagaOwnership, parseManifest } from "@suaveplan/api-manifest";
const result = validateSagaOwnership(parseManifest(raw));
if (!result.ok) throw result.error;
```
## Advanced Example
```ts
import { validateSagaOwnership, parseManifest } from "@suaveplan/api-manifest";
const manifest = parseManifest(raw);
const result = validateSagaOwnership(manifest);
if (!result.ok) {
console.error("offenders", result.error.context?.offenders);
}
```
## API Reference
- `validateSagaOwnership(manifest: PluginManifest): ValidationResult`
- Failure code: `MANIFEST.INVALID_SAGA_OWNERSHIP` with `context.offenders: { saga, step, plugin }[]`.
## Implementation Notes
The validator collects every offender in one pass instead of fail-fast on the first; admin tooling needs the full list to render a single diagnostic dialog rather than make the operator fix-rerun-fix-rerun. `participatesIn` is intentionally not cross-checked here — that's a host-level concern because the owning plugin lives in a different manifest and the host has the full set. This validator is bounded by the size of the local manifest only.
## Cross-package Notes
This module is part of the `@suaveplan/api-manifest` Stage-1 contract package. It sits beneath every Stage-2 consumer (`api-host`, `api-loader`, the admin CLI, the IDE plugin) and above the foundation packages (`@suaveplan/error`, `@suaveplan/types`). The strict-mode top-level rejection of unknown keys means upgrading from manifest v1 to v2 is an OpenSpec-driven event: a new key landing in a consumer manifest is a parse failure rather than a silent drift, which is exactly the contract this stage exists to guarantee.
When the host reloads a plugin without a full restart, every manifest field this module validates is compared against the install-time `manifestHash`. A mismatch triggers a hard rollback via the `RollbackJournal` because the host's invariants (route mounts, registered capabilities, declared scopes) are derived from these exact bytes. Treat the schemas and validators here as the contract surface, not just an input filter — downstream code reads `manifest.foo.bar` and relies on those keys being present, well-typed, and non-undefined in the documented optionality regions.