Initial Commit

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

View File

@@ -0,0 +1,97 @@
# Add @suaveplan/api-plugin-vault — per-user vault routes wrapping @suaveplan/vault (3-way plug-and-play)
**Package**: `@suaveplan/api-plugin-vault` (NEW, server-tier)
**Status**: PROPOSED
**Type**: new-package
**Position**: depends on `add-api-plugin-auth` (+ optionally `add-api-plugin-user`)
- **Spec delta**: ./specs/api-plugin-vault/spec.md
## Why
Genesis already ships `@suaveplan/vault` — the universal encrypted-vault library (open/unlock/lock FSM, items + attachments, container backed by `DriveLike`). What's missing is the HTTP surface: operators building an app with `@suaveplan/api` want `/vault/items` routes that look up the authenticated principal, scope the vault to that user, and apply Better-Auth's session for authorisation. This plugin ships exactly that — three CRUD route groups (`items`, `attachments`, `shares` — items only for v1) plus a `vaultService` for downstream plugins.
Third instance of the **3-way plug-and-play contract**: SurrealQL schema for vault ownership/sharing metadata, Zod schemas at every request boundary, OpenAPI routes registered through `definePluginRoutes`. The encryption / decryption stays inside `@suaveplan/vault`; this plugin is the HTTP wrapper + per-user scoping layer only.
## What changes
Net-new package at `0.1.0`. ADDED:
### Plugin factory + descriptor
- `createVaultPlugin(options): PluginDescriptor<VaultPluginConfig>` — `definePlugin` consumer with `dependencies: ["auth"]` (user plugin optional). `options` Zod-validated.
- `VaultPluginOptions` (Zod-validated): `{ routePrefix?: string (default "/vault"); storage: DriveLike; maxItemSizeBytes?: number (default 10 * 1024 * 1024); applySchemaOnInit?: boolean (default true); logger?: LoggerLike }`. `storage` is the `DriveLike` instance the vault library writes encrypted blobs to (operator supplies — could be `@suaveplan/storage-s3`-backed `@suaveplan/drive`, local fs, etc.).
### Routes (5)
All built with `RouteBuilder`; all gated by `requireAuth()` upstream. Every route scopes the vault to `c.get("user")!.id`:
- `GET /vault/items` — list items owned by the principal. Response: `VaultItemListResponseSchema` (paginated).
- `POST /vault/items` — create a new item. Body: `CreateVaultItemBodySchema`. Response: `VaultItemResponseSchema`.
- `GET /vault/items/:id` — fetch a single item. Response: `VaultItemResponseSchema`. 404 + `VAULT_ITEM_NOT_FOUND` when the id doesn't exist or isn't owned by the principal.
- `PATCH /vault/items/:id` — update fields. Body: `UpdateVaultItemBodySchema` (strict mode). Response: `VaultItemResponseSchema`.
- `DELETE /vault/items/:id` — remove. Response: `{ deleted: true }`. Owner-only or `VAULT_FORBIDDEN`.
### vaultService
- `vaultService` registered via `ctx.registerService("vaultService", ...)`. Surface: `{ openForPrincipal(principal: Principal, password: string): Promise<VaultHandle>; listItemsForOwner(ownerId: string): Promise<VaultItemSummary[]>; close(handle: VaultHandle): Promise<void> }`.
- ServiceRegistry augmentation: `declare module "@suaveplan/api-plugin/service-registry" { interface ServiceRegistry { vaultService: VaultService } }`.
- The principal's unlock password is NEVER stored — passed per request via a header (`X-Vault-Unlock`) or extracted from a separate per-user secret store. Default extractor: `headerExtractor("X-Vault-Unlock")` (operators override via `options.unlockExtractor`).
### .surql schema (plug-and-play point 1)
- `./schema` exports `VAULT_OWNERSHIP_SCHEMA` (frozen string) — DDL for two tables:
- `vault_item`: `id`, `ownerId: record<user>` (record-link), `containerKey: string`, `title: string`, `kind: string`, `metadata: option<object>`, `createdAt`, `updatedAt`. Indexed on `(ownerId, kind)` and `(ownerId, createdAt DESC)`.
- `vault_share`: forward-declared for future sharing-route follow-up (`ownerId: record<user>`, `granteeId: record<user>`, `itemId: record<vault_item>`, `permissions: array<string>`, `grantedAt`, `revokedAt: option<datetime>`).
- DDL uses `DEFINE TABLE OVERWRITE` for idempotency. Genesis-original (no upstream port; the vault library doesn't define ownership metadata — that's HTTP-layer concern).
- `applyVaultSchema(adapter): Promise<void>` convenience helper. `onInit` applies via `migrationRunner` (preferred) or `db` fallback.
### Zod schemas (plug-and-play point 2) — exposed at `./schemas/*`
- `./schemas/vault-item` — `VaultItemSchema`, `VaultItemSummarySchema`, `VaultItemResponseSchema`, `VaultItemListResponseSchema`, `CreateVaultItemBodySchema`, `UpdateVaultItemBodySchema` (strict mode).
- `./schemas/options` — `VaultPluginOptionsSchema`.
### Errors + observability
- `VaultPluginError extends SuaveplanError<VaultPluginErrorCode>` via `defineCodes("VAULT_PLUGIN", [...])`. Codes: `VAULT_PLUGIN_INVALID_INPUT`, `VAULT_PLUGIN_ITEM_NOT_FOUND`, `VAULT_PLUGIN_FORBIDDEN`, `VAULT_PLUGIN_UNLOCK_FAILED`, `VAULT_PLUGIN_ITEM_TOO_LARGE`, `VAULT_PLUGIN_SERVICE_NOT_AVAILABLE`, `VAULT_PLUGIN_SCHEMA_APPLY_FAILED`, `VAULT_PLUGIN_ROUTE_PREFIX_COLLISION`, `VAULT_PLUGIN_UNLOCK_HEADER_MISSING`.
- OTel: counter `vault.items.created_total{outcome}`, counter `vault.items.read_total{outcome}`, counter `vault.items.updated_total{outcome}`, counter `vault.items.deleted_total{outcome}`, histogram `vault.item.size_bytes` (on create + update — distribution of item sizes).
### Subpath exports
`.`, `./plugin/plugin`, `./routes/routes`, `./services/vault-service`, `./schemas/vault-item`, `./schemas/options`, `./schema/schema`, `./error-codes/error-codes`, `./otel/instruments`, `./types/types`.
## Out of scope
- **Sharing / collaborator endpoints.** The `vault_share` table is forward-declared in the schema but no routes ship — follow-up `api-plugin-vault-sharing`.
- **Attachments routes** (`/vault/items/:id/attachments`). Vault library supports attachments; HTTP routes are a follow-up `api-plugin-vault-attachments`.
- **Vault discovery / cross-user search.** Per-user only in v1.
- **Vault key escrow / recovery flows.** Operator concern; vault library has primitives, this plugin doesn't surface them.
- **Multi-vault per user.** One vault per user in v1; multi-vault is a follow-up.
- **WebDAV / SFTP gateways.** HTTP routes only.
## Source mapping
This package is **genesis-original**. Design intent draws from:
- The 3-way plug-and-play contract established by `add-api-plugin-auth`.
- `@suaveplan/vault` shipped surface (`VaultManager`, item/attachment APIs).
- `@suaveplan/api-plugin-auth/middleware/require-auth` + `Principal`.
- The legacy `enterprise-api-plugin-vault.md` proposal's route-surface intent (verbatim: `/vault/items` CRUD).
| Requirement | Source |
|---|---|
| `definePlugin` consumer w/ `dependencies: ["auth"]` | `@suaveplan/api-plugin` |
| Per-user scoping via `c.get("user").id` | `@suaveplan/api-plugin-auth` (sibling) |
| Vault encryption / FSM | `@suaveplan/vault` (shipped) |
| `RouteBuilder` end-to-end Zod | `@suaveplan/api-openapi` (shipped) |
| 3-way plug-and-play (`.surql` + Zod + OpenAPI) | genesis architectural principle |
## Impact
### Affected dependents
None today — net-new. Future `api-plugin-vault-sharing` / `api-plugin-vault-attachments` follow-ups will depend on this.
### Catalog additions
None — every dep already in catalogs.

View File

@@ -0,0 +1,178 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-vault (add-api-plugin-vault)
**Package**: `@suaveplan/api-plugin-vault`
**Status**: PROPOSED
## ADDED Requirements
### Requirement: createVaultPlugin returns a Zod-validated descriptor with auth dependency
The package SHALL export `createVaultPlugin(options): PluginDescriptor<VaultPluginConfig>` Zod-validating `options` via `VaultPluginOptionsSchema`. The descriptor SHALL declare `name: "vault"` and `dependencies: ["auth"]`. Invalid options raise `VaultPluginError(VAULT_PLUGIN_INVALID_INPUT)` with `cause instanceof ZodError`.
#### Scenario: descriptor declares auth dependency
- **WHEN** `createVaultPlugin({ storage })` is called with a valid storage
- **THEN** the descriptor's `dependencies` contains `"auth"`
#### Scenario: missing storage raises VAULT_PLUGIN_INVALID_INPUT
- **WHEN** `createVaultPlugin({} as never)` is called
- **THEN** `VaultPluginError(VAULT_PLUGIN_INVALID_INPUT)` is thrown
**Verified by:** `src/plugin/plugin.test.ts::"descriptor declares auth dependency"` AND `src/plugin/plugin.test.ts::"missing storage raises VAULT_PLUGIN_INVALID_INPUT"`
---
### Requirement: onInit applies VAULT_OWNERSHIP_SCHEMA (plug-and-play point 1)
`onInit(ctx)` SHALL resolve `migrationRunner` (preferred) or `db` (fallback) from `ServiceRegistry`. When `applySchemaOnInit === true` (default), `VAULT_OWNERSHIP_SCHEMA` is applied; both `vault_item` and `vault_share` tables created with their indexes. Missing services raise `VaultPluginError(VAULT_PLUGIN_SERVICE_NOT_AVAILABLE)`.
#### Scenario: schema creates vault_item and vault_share tables
- **WHEN** the plugin initializes against a real SurrealDB testcontainer (with the auth schema applied first)
- **THEN** `INFO FOR DB` lists both `vault_item` and `vault_share` tables AND the expected indexes exist
#### Scenario: applySchemaOnInit: false skips the step
- **WHEN** `createVaultPlugin({ storage, applySchemaOnInit: false })` initializes
- **THEN** the schema apply step is skipped
**Verified by:** `src/plugin/on-init.test.ts::"applies VAULT_OWNERSHIP_SCHEMA via migrationRunner"` AND `src/plugin/on-init.test.ts::"creates vault_item + vault_share tables"` AND `src/plugin/on-init.test.ts::"applySchemaOnInit: false skips apply"`
---
### Requirement: 5 routes registered with RouteBuilder + Zod end-to-end (plug-and-play points 2 + 3)
`onInit` SHALL call `definePluginRoutes(buildVaultRoutes(vaultService, authService), { app, registry, pluginName: "vault" })` registering: `GET /vault/items`, `POST /vault/items`, `GET /vault/items/:id`, `PATCH /vault/items/:id`, `DELETE /vault/items/:id`. Every route uses `requireAuth()` upstream. Every body/query/response is a Zod schema from `./schemas/vault-item`. All routes scope to `c.get("user")!.id` — items from other users SHALL appear as `VAULT_PLUGIN_ITEM_NOT_FOUND` to prevent enumeration.
#### Scenario: list returns only the principal's items
- **WHEN** principal A owns 3 items and principal B owns 2; A is authenticated; GET /vault/items
- **THEN** the response contains 3 items, all `ownerId === A.id`
#### Scenario: GET /vault/items/:id of foreign item returns ITEM_NOT_FOUND
- **WHEN** principal A is authenticated and GETs an item owned by principal B
- **THEN** the response is 404 with `VAULT_PLUGIN_ITEM_NOT_FOUND` (NOT 403 — prevents enumeration)
#### Scenario: PATCH /vault/items/:id rejects unknown fields
- **WHEN** authenticated PATCH with `{ title: "X", evil: "Y" }`
- **THEN** the response is 400 from `UpdateVaultItemBodySchema` Zod strict-mode validation
#### Scenario: DELETE /vault/items/:id of foreign item returns FORBIDDEN
- **WHEN** principal A DELETEs an item that exists but is owned by B
- **THEN** the response is 404 (not 403, same anti-enumeration as GET)
**Verified by:** `src/routes/list.test.ts::"returns only principal's items"` AND `src/routes/get.test.ts::"foreign item returns ITEM_NOT_FOUND"` AND `src/routes/patch.test.ts::"rejects unknown fields"` AND `src/routes/delete.test.ts::"foreign item returns 404"` AND `tests/integration/openapi-doc.test.ts::"served /openapi.json includes 5 /vault/items routes"`
---
### Requirement: Item size enforced via maxItemSizeBytes
`POST /vault/items` and `PATCH /vault/items/:id` SHALL reject bodies whose encrypted payload exceeds `options.maxItemSizeBytes` (default 10 MiB) with `VaultPluginError(VAULT_PLUGIN_ITEM_TOO_LARGE)` (413).
#### Scenario: item over limit raises ITEM_TOO_LARGE
- **WHEN** `maxItemSizeBytes: 1024` and an authenticated POST sends a body larger than 1 KiB
- **THEN** the response is 413 with `VAULT_PLUGIN_ITEM_TOO_LARGE`
**Verified by:** `src/routes/create.test.ts::"oversize body raises ITEM_TOO_LARGE"`
---
### Requirement: vaultService registered with typed slot
`onInit` registers `ctx.registerService("vaultService", { openForPrincipal, listItemsForOwner, close })`. ServiceRegistry augmentation adds `vaultService: VaultService` with full type inference.
#### Scenario: openForPrincipal returns VaultHandle
- **WHEN** `vaultService.openForPrincipal(principal, password)` is awaited with a valid password
- **THEN** the result is a `VaultHandle` opaque type (no leak of internal vault state)
#### Scenario: bad password raises UNLOCK_FAILED
- **WHEN** `openForPrincipal` is called with the wrong password
- **THEN** `VaultPluginError(VAULT_PLUGIN_UNLOCK_FAILED)` is thrown
**Verified by:** `src/services/vault-service.test.ts::"openForPrincipal returns VaultHandle"` AND `src/services/vault-service.test.ts::"bad password raises UNLOCK_FAILED"` AND `src/services/vault-service.spec-conformance.test.ts::"ServiceRegistry slot typed as VaultService"`
---
### Requirement: Unlock password sourced via configurable extractor
The package SHALL accept `options.unlockExtractor?: (c: Context) => string | null` (default `headerExtractor("X-Vault-Unlock")`). When the extractor returns null on a route that requires unlock, the response SHALL be `VaultPluginError(VAULT_PLUGIN_UNLOCK_HEADER_MISSING)` (400). The plugin SHALL NEVER log or store the extracted password.
#### Scenario: default extractor reads X-Vault-Unlock header
- **WHEN** an authenticated POST /vault/items includes `X-Vault-Unlock: hunter2`
- **THEN** the plugin uses `"hunter2"` to open the vault for the principal
#### Scenario: missing extractor result raises UNLOCK_HEADER_MISSING
- **WHEN** an authenticated POST /vault/items omits the unlock header
- **THEN** the response is 400 with `VAULT_PLUGIN_UNLOCK_HEADER_MISSING`
#### Scenario: password never logged
- **WHEN** a logger captures every line emitted during a POST /vault/items run
- **THEN** none of the captured lines contain the extracted password substring
**Verified by:** `src/plugin/unlock.test.ts::"default extractor reads X-Vault-Unlock"` AND `src/plugin/unlock.test.ts::"missing header raises UNLOCK_HEADER_MISSING"` AND `src/plugin/unlock.test.ts::"password never logged"`
---
### Requirement: Zod schemas exported at ./schemas/* (plug-and-play point 2)
The package SHALL export schemas at `./schemas/vault-item` (`VaultItemSchema`, `VaultItemSummarySchema`, `VaultItemResponseSchema`, `VaultItemListResponseSchema`, `CreateVaultItemBodySchema`, `UpdateVaultItemBodySchema` strict mode) and `./schemas/options` (`VaultPluginOptionsSchema`). Every schema has a corresponding `z.infer` type exported alongside.
#### Scenario: UpdateVaultItemBodySchema is strict
- **WHEN** `UpdateVaultItemBodySchema.safeParse({ title: "X", evil: "Y" })` is called
- **THEN** `success === false`
#### Scenario: CreateVaultItemBodySchema requires title and kind
- **WHEN** `CreateVaultItemBodySchema.safeParse({})` is called
- **THEN** `success === false` (missing `title` and `kind`)
**Verified by:** `src/schemas/vault-item.test.ts::"UpdateVaultItemBodySchema is strict"` AND `src/schemas/vault-item.test.ts::"CreateVaultItemBodySchema requires title and kind"`
---
### Requirement: Every VAULT_PLUGIN_* code is thrown from a production path
`VAULT_PLUGIN_CODES` SHALL define exactly 9 codes (`VAULT_PLUGIN_INVALID_INPUT`, `VAULT_PLUGIN_ITEM_NOT_FOUND`, `VAULT_PLUGIN_FORBIDDEN`, `VAULT_PLUGIN_UNLOCK_FAILED`, `VAULT_PLUGIN_ITEM_TOO_LARGE`, `VAULT_PLUGIN_SERVICE_NOT_AVAILABLE`, `VAULT_PLUGIN_SCHEMA_APPLY_FAILED`, `VAULT_PLUGIN_ROUTE_PREFIX_COLLISION`, `VAULT_PLUGIN_UNLOCK_HEADER_MISSING`). Every code SHALL be raised by ≥1 production call site.
**Verified by:** `src/api-plugin-vault.spec-conformance.test.ts::"every VAULT_PLUGIN_* code is thrown from a production path"`
---
### Requirement: OpenTelemetry counters + histogram emit per operation
The package SHALL register counters `vault.items.created_total{outcome}`, `vault.items.read_total{outcome}`, `vault.items.updated_total{outcome}`, `vault.items.deleted_total{outcome}` and histogram `vault.item.size_bytes` (on create + update).
#### Scenario: created_total emits success
- **WHEN** a successful POST /vault/items runs against a real in-memory `MeterProvider`
- **THEN** `vault.items.created_total{outcome: "success"}` has 1 observation AND `vault.item.size_bytes` has 1 observation
**Verified by:** `src/otel/instruments.test.ts::"created_total emits success"` AND `src/otel/instruments.test.ts::"size_bytes histogram emits on create + update"`
---
### Requirement: Server-tier package shape + full integration test
Package SHALL run in Bun + Node. `package.json` SHALL declare subpath exports per feature module. Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. No `export *` in feature barrels. 100/100/100/100 coverage. Integration test SHALL exercise the full plugin alongside `api-plugin-auth` + a real `@suaveplan/vault` instance with in-memory `DriveLike` against a SurrealDB testcontainer: sign-up via auth → POST /vault/items → GET /vault/items → PATCH → DELETE.
#### Scenario: full integration roundtrip
- **WHEN** `tests/integration/full-boot.test.ts` builds a real app with auth + this plugin, signs up, then POSTs/GETs/PATCHes/DELETEs a vault item with `X-Vault-Unlock` header
- **THEN** every step succeeds AND a subsequent GET /vault/items returns an empty list
**Verified by:** `tests/integration/full-boot.test.ts::"sign-up → POST → GET → PATCH → DELETE /vault/items roundtrip against real SurrealDB"` AND `tests/integration/openapi-doc.test.ts::"served doc lists 5 /vault/items routes"` AND `tests/integration/cross-user-isolation.test.ts::"user A cannot see / modify user B's items"` AND `tests/dep-graph.test.ts::"every subpath export resolves"` AND `tests/dep-graph.test.ts::"every impl .ts has a sibling .md"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,53 @@
# Tasks — add-api-plugin-vault
## 1. Scaffold
- [ ] 1.1 Create `packages/api/api-plugin-vault/` with `src/{plugin,routes,services,schemas,schema,error-codes,otel,types}/` + `tests/{setup,integration}/`
- [ ] 1.2 `package.json` v0.1.0. Deps: `@suaveplan/api-plugin` `@suaveplan/api-openapi` `@suaveplan/api-migrations` `@suaveplan/api-plugin-auth` `@suaveplan/vault` `@suaveplan/error` `@suaveplan/types` `tslib`, `hono` `@hono/zod-openapi` (`catalog:server`), `zod` (`catalog:forms`), `@opentelemetry/api`
- [ ] 1.3 10 subpath exports per the proposal
- [ ] 1.4 tsconfigs + vite + vitest (100/100/100/100, testTimeout 60000 for integration)
- [ ] 1.5 `tests/setup/polyfills.ts`
## 2. Implementation
- [ ] 2.1 `src/error-codes.{ts,test.ts,md,index.ts}` — 9 VAULT_PLUGIN.* codes + `VaultPluginError`
- [ ] 2.2 `src/types/types.{ts,md,index.ts}` — `VaultPluginOptions`, `VaultPluginConfig`, `VaultHandle` (opaque), `VaultItem`, `VaultItemSummary`
- [ ] 2.3 `src/schemas/options.{ts,test.ts,md,index.ts}` — `VaultPluginOptionsSchema`
- [ ] 2.4 `src/schemas/vault-item.{ts,test.ts,md,index.ts}` — 6 schemas (item / summary / response / list response / create body / update body strict-mode)
- [ ] 2.5 `src/otel/instruments.{ts,test.ts,md,index.ts}` — 4 counters + 1 histogram
- [ ] 2.6 `src/schema/schema.{ts,test.ts,md,index.ts}` — `VAULT_OWNERSHIP_SCHEMA` (two tables + indexes) + `applyVaultSchema(adapter)` helper
- [ ] 2.7 `src/services/vault-service.{ts,test.ts,md,index.ts}` — `VaultService` interface + impl wrapping `@suaveplan/vault`; ServiceRegistry augmentation
- [ ] 2.8 `src/plugin/unlock.{ts,test.ts,md,index.ts}` — `headerExtractor("X-Vault-Unlock")` default + `extractUnlock(c, options)` helper; no logging guard
- [ ] 2.9 `src/routes/build-routes.{ts,test.ts,md,index.ts}` — `buildVaultRoutes(vaultService, authService)` returning 5 `PluginRouteEntry` via `RouteBuilder`; every route uses `requireAuth()` upstream
- [ ] 2.10 `src/plugin/plugin.{ts,test.ts,md,index.ts}` — `createVaultPlugin(options)` factory; `dependencies: ["auth"]`
- [ ] 2.11 `src/index.ts` — named re-exports only
## 3. Tests
- [ ] 3.1 Co-located `*.test.ts` per spec scenario
- [ ] 3.2 `src/api-plugin-vault.spec-conformance.test.ts` — every VAULT_PLUGIN.* code thrown; `expectTypeOf` on `VaultService`, `VaultHandle` is opaque
- [ ] 3.3 `tests/integration/full-boot.test.ts` — real PluginRegistry + auth + this plugin + real `@suaveplan/vault` with in-memory `DriveLike` + SurrealDB testcontainer; sign-up → vault CRUD roundtrip
- [ ] 3.4 `tests/integration/openapi-doc.test.ts` — served `/openapi.json` includes 5 vault routes
- [ ] 3.5 `tests/integration/cross-user-isolation.test.ts` — user A cannot see / modify / delete user B's items (404 not 403)
- [ ] 3.6 `tests/property.test.ts` — fast-check 200 runs on `CreateVaultItemBodySchema` (size invariant: payloads over `maxItemSizeBytes` always rejected)
- [ ] 3.7 `tests/dep-graph.test.ts`
- [ ] 3.8 All tests via `@suaveplan/testing/runner`, `{ timeout: 5000 }` (60000 for integration)
## 4. Documentation
- [ ] 4.1 Sibling `.md` per impl (≥200 words)
- [ ] 4.2 `README.md` — install + `defineApp().use(authPlugin).use(vaultPlugin({ storage }))` example; X-Vault-Unlock header semantics + alternatives (jwt-bound, derived from password etc); cross-user isolation security note (404 not 403); schema apply notes
## 5. Gates
- [ ] 5.1 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-vault --force`
- [ ] 5.2 Coverage 100/100/100/100
- [ ] 5.3 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-vault` exit 0
- [ ] 5.4 Build green
- [ ] 5.5 Banned-pattern + no `export *` + no direct runner imports
## 6. Archive
- [ ] 6.1 Fold spec → `openspec/specs/api-plugin-vault/spec.md`
- [ ] 6.2 mv change → `openspec/archive/<date>-add-api-plugin-vault/`
- [ ] 6.3 DO NOT refresh MANIFEST or STATUS — orchestrator consolidates