Files
2026-05-27 23:29:53 +01:00

201 lines
14 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec: audio-ir-core
**Package**: `@suaveplan/audio-ir-core`
**Status**: SHIPPED
> Every requirement carries a `**Verified by:**` line.
## Requirements
### Requirement: `ImpulseResponse` and `IrManifestEntry` types are frozen and Zod-validated
The package SHALL export `ImpulseResponse` and `IrManifestEntry` TypeScript types AND companion Zod schemas (`ImpulseResponseSchema`, `IrManifestEntrySchema`) that exhaustively validate every field. Validated objects SHALL be frozen with `Object.freeze` before being returned by any factory or registry method. `IrManifestEntry.id` SHALL match `/^[a-z][a-z0-9-]*$/`, SHALL NOT contain consecutive `-` characters, AND SHALL be `1..128` bytes long.
#### Scenario: valid entry round-trips through Zod
- **WHEN** `IrManifestEntrySchema.parse({ id: "club-club1", family: "room", uri: "bundle://x/y.wav", channels: 2, sampleRate: 44100, gainDb: 0, license: "MIT", source: "demo" })` is called
- **THEN** the result deep-equals the input AND is frozen
#### Scenario: malformed id rejected
- **WHEN** any of `IrManifestEntrySchema.parse({ id: "Club1", ... })`, `IrManifestEntrySchema.parse({ id: "club--1", ... })`, `IrManifestEntrySchema.parse({ id: "1-club", ... })` is called
- **THEN** each call throws a `ZodError`
**Verified by:** `src/types/manifest-entry.test.ts::"valid entry round-trips through Zod and is frozen"` AND `src/types/manifest-entry.test.ts::"rejects uppercase / consecutive-dash / non-letter-start ids"`
---
### Requirement: `IrManifest` enforces id-prefix and family coherence
The `IrManifestSchema` SHALL refine every parsed manifest so that (a) every `entry.id` begins with `<manifest.packageSlug>-` (case-sensitive), AND (b) every `entry.family` equals `manifest.family`. Violations SHALL surface as `AUDIO_IR.ID_PREFIX_MISMATCH` and `AUDIO_IR.FAMILY_MISMATCH` respectively when the validation is invoked through the registry; raw `ZodError` is also acceptable when callers parse the schema directly.
#### Scenario: id without slug prefix is rejected
- **WHEN** the registry is asked to `register({ id: "wrong-1", ... })` from a manifest whose `packageSlug === "club"`
- **THEN** the call throws `AudioIrError` with `code === "AUDIO_IR.ID_PREFIX_MISMATCH"`, `expectedPrefix === "club-"`, `actualId === "wrong-1"`
#### Scenario: entry family disagreeing with manifest family is rejected
- **WHEN** the registry is asked to `register({ id: "club-x", family: "mic", ... })` from a manifest whose `family === "room"`
- **THEN** the call throws `AudioIrError` with `code === "AUDIO_IR.FAMILY_MISMATCH"`, `manifestFamily === "room"`, `entryFamily === "mic"`
**Verified by:** `src/types/manifest.test.ts::"rejects entry whose id lacks the manifest slug prefix"` AND `src/registry/registry.test.ts::"throws FAMILY_MISMATCH when entry family disagrees with manifest"`
---
### Requirement: Registry register / has / get / list / clear semantics
`createIrRegistry()` SHALL return an `IrRegistry` exposing:
1. `register(entry: IrManifestEntry, context: { manifestSlug: string; manifestFamily: IrFamily }): void` — validates `entry` via `IrManifestEntrySchema`, then enforces ID-prefix and family-coherence (per the prior requirement), then inserts into an internal `Map<string, IrManifestEntry>`. Throws `AUDIO_IR.DUPLICATE_ID` when the id is already registered.
2. `has(id: string): boolean` — O(1) lookup.
3. `get(id: string): IrManifestEntry` — O(1) lookup; throws `AUDIO_IR.NOT_REGISTERED` when absent.
4. `list({ family? }: { family?: IrFamily } = {}): readonly IrManifestEntry[]` — stable insertion-order enumeration, filtered when `family` is supplied.
5. `clear(): void` — resets internal state. Intended for test setups.
#### Scenario: register then get returns the registered entry
- **WHEN** `registry.register(entry, { manifestSlug: "club", manifestFamily: "room" })` succeeds AND `registry.get(entry.id)` is called
- **THEN** the returned object is identity-equal (`===`) to the entry passed in, AND it is frozen
#### Scenario: duplicate id throws
- **WHEN** `registry.register` is invoked twice with two distinct entry objects that share the same `id`
- **THEN** the second call throws `AudioIrError` with `code === "AUDIO_IR.DUPLICATE_ID"` AND `id` equal to the duplicated identifier; the first registration remains in place
#### Scenario: list filters by family in stable insertion order
- **WHEN** four entries are registered in order `[r1(room), m1(mic), r2(room), s1(speaker)]` AND `registry.list({ family: "room" })` is called
- **THEN** the result is `[r1, r2]` (insertion order; `m1` and `s1` excluded)
#### Scenario: get against an unknown id throws NOT_REGISTERED
- **WHEN** `registry.get("nope")` is called against an empty registry
- **THEN** the call throws `AudioIrError` with `code === "AUDIO_IR.NOT_REGISTERED"` AND `id === "nope"`
#### Scenario: clear wipes the registry
- **WHEN** entries are registered AND `registry.clear()` is called AND `registry.list()` is invoked
- **THEN** `list()` returns `[]` AND every previously-registered id reports `has(id) === false`
**Verified by:** `src/registry/registry.test.ts::"register then get returns the same frozen entry"` AND `src/registry/registry.test.ts::"throws DUPLICATE_ID on second register of same id"` AND `src/registry/registry.test.ts::"list filters by family in stable insertion order"` AND `src/registry/registry.test.ts::"get throws NOT_REGISTERED for unknown id"` AND `src/registry/registry.test.ts::"clear empties the registry"`
---
### Requirement: Manifest registration helper
The package SHALL export `registerManifest(registry, manifest)` that iterates `manifest.entries` and calls `registry.register(entry, { manifestSlug: manifest.packageSlug, manifestFamily: manifest.family })` for each. On any failure (`DUPLICATE_ID`, `ID_PREFIX_MISMATCH`, `FAMILY_MISMATCH`, or Zod parse) the helper SHALL rethrow the original error verbatim WITHOUT rolling back prior registrations — the registry is allowed to be partially populated, since the failure surface is asserted before any IO and a re-run after fixing the manifest will hit `DUPLICATE_ID` on the partial entries, surfacing the conflict immediately. Callers that need atomicity SHALL use a fresh `createIrRegistry()` per attempt.
#### Scenario: well-formed manifest registers every entry
- **WHEN** `registerManifest(registry, { packageSlug: "club", family: "room", entries: [eA, eB] })` is called
- **THEN** `registry.has(eA.id)` and `registry.has(eB.id)` are both `true` AND `registry.list({ family: "room" })` includes both
#### Scenario: failure mid-manifest leaves registry partially populated
- **WHEN** `registerManifest(registry, { packageSlug: "club", family: "room", entries: [eA, badEntry, eC] })` is called and `badEntry` fails `ID_PREFIX_MISMATCH`
- **THEN** the call throws AND `registry.has(eA.id) === true` AND `registry.has(eC.id) === false`
**Verified by:** `src/registry/register-manifest.test.ts::"registers every entry in a well-formed manifest"` AND `src/registry/register-manifest.test.ts::"leaves registry partially populated on mid-manifest failure"`
---
### Requirement: OpenTelemetry instrumentation
Every public registry method SHALL emit a span on the `@suaveplan/audio-ir-core` tracer. Successful `register` SHALL increment `audio_ir.registrations_total{family, outcome:"success"}`. Successful `get`/`has` SHALL increment `audio_ir.lookups_total{family, outcome:"hit"|"miss"}`. Every `AUDIO_IR.*` throw SHALL increment `audio_ir.errors_total{code}`. The package SHALL register an observable gauge `audio_ir.registry_size{family}` that reports the current count per family on each scrape.
#### Scenario: register success increments registrations_total
- **WHEN** `registry.register` succeeds for a `room`-family entry
- **THEN** `audio_ir.registrations_total{family:"room", outcome:"success"}` is incremented by 1
#### Scenario: error throw increments errors_total with the resolved code
- **WHEN** `registry.get("nope")` throws `NOT_REGISTERED`
- **THEN** `audio_ir.errors_total{code:"AUDIO_IR.NOT_REGISTERED"}` is incremented by 1
#### Scenario: registry_size gauge reports per-family count
- **WHEN** two `room` and one `mic` entries are registered AND the gauge is sampled
- **THEN** the sample yields `audio_ir.registry_size{family:"room"} === 2` AND `audio_ir.registry_size{family:"mic"} === 1`
**Verified by:** `src/otel/otel.test.ts::"increments registrations_total on successful register"` AND `src/otel/otel.test.ts::"increments errors_total on every AUDIO_IR throw"` AND `src/otel/otel.test.ts::"registry_size gauge reports per-family counts"`
---
### Requirement: Error-code registry exhaustion
The `AUDIO_IR_CODES` registry exposed via `defineCodes("AUDIO_IR", [...])` SHALL contain exactly the five codes declared in the proposal: `DUPLICATE_ID`, `NOT_REGISTERED`, `INVALID_MANIFEST`, `ID_PREFIX_MISMATCH`, `FAMILY_MISMATCH`. Every code SHALL be reachable from a real call site exercised by the test suite (AGENTS §2c).
#### Scenario: every code is reachable from a real call site
- **WHEN** the test suite enumerates `Object.values(AUDIO_IR_CODES)` AND maps each to a fixture call site
- **THEN** every code maps to at least one `it(...)` assertion that exercises the code from a real call site
**Verified by:** `src/error-codes/error-codes.test.ts::"every AUDIO_IR code reachable from a real call site"`
---
### Requirement: `MutableUriPluginRegistrar` is the canonical collector interface for IR content packages
The package SHALL export `MutableUriPluginRegistrar` — a one-method interface of shape `{ use(plugin: UriPlugin): void }` where `UriPlugin` is the type from `@suaveplan/storage-uri`. The interface SHALL be the canonical target type for `register(registry, target, options?)` across every `audio-ir-{family}-plugin-<name>` content package, replacing the locally-defined copy in `audio-ir-room-plugin-pool` and the divergent `(registry, resolver)` shape in `audio-ir-room-plugin-club`.
The interface SHALL NOT extend `StorageUriResolver` and SHALL NOT reference any resolver type — the collector pattern is deliberately decoupled from resolver construction so that consumers compose plugin lists freely (single resolver, parent + inner, future builder pattern).
#### Scenario: interface is exported from the package root barrel
- **WHEN** a caller imports `{ MutableUriPluginRegistrar }` from `@suaveplan/audio-ir-core`
- **THEN** the import resolves AND the resulting type accepts `{ use: (p: UriPlugin) => void }` (verified via `expectTypeOf`)
#### Scenario: interface accepts a single-method object as target
- **WHEN** a caller passes `{ use: (p) => pluginArray.push(p) }` to any `audio-ir-{family}-plugin-<name>` package's `register` function
- **THEN** the call typechecks AND `register` invokes `use` exactly once per bundle plugin
**Verified by:** `src/registrar/registrar.spec-conformance.test.ts::"MutableUriPluginRegistrar accepts a single-method collector object"` AND `src/registrar/registrar.spec-conformance.test.ts::"MutableUriPluginRegistrar is exported from the root barrel"`
---
### Requirement: `createUriPluginCollector()` factory wraps an ordered list
The package SHALL export `createUriPluginCollector()` returning an object that satisfies `MutableUriPluginRegistrar` AND additionally exposes `plugins: readonly UriPlugin[]`. The internal storage SHALL be an array; `use(plugin)` SHALL push, preserving insertion order. The `plugins` accessor SHALL return a defensive snapshot — a frozen array — so that callers cannot mutate the collector's internal state after reading. Calling `use` after reading `plugins` SHALL still work; subsequent `plugins` reads return a new frozen snapshot reflecting the latest state.
#### Scenario: collector exposes pushed plugins in order
- **WHEN** `c = createUriPluginCollector()` is constructed AND `c.use(pA)`, `c.use(pB)`, `c.use(pC)` are invoked in that order
- **THEN** `c.plugins` equals `[pA, pB, pC]` (referential equality on each entry, declared order)
#### Scenario: plugins snapshot is frozen
- **WHEN** `c.plugins` is read
- **THEN** the returned array is `Object.isFrozen(c.plugins) === true` AND attempting to mutate it (`(c.plugins as UriPlugin[]).push(pX)`) throws a `TypeError` under strict mode
#### Scenario: subsequent use() after plugins read still works
- **WHEN** `c.plugins` is read AND `c.use(pD)` is then invoked AND `c.plugins` is read a second time
- **THEN** the second read returns a new array containing `pD` (the first read's snapshot remains the same — it is frozen)
**Verified by:** `src/registrar/registrar.test.ts::"collector preserves insertion order across use() calls"` AND `src/registrar/registrar.test.ts::"plugins snapshot is frozen and rejects mutation"` AND `src/registrar/registrar.test.ts::"use() after plugins read reflects in the next snapshot"`
---
### Requirement: Package barrel re-exports both additions
The package's root barrel SHALL re-export both `MutableUriPluginRegistrar` (as a type) AND `createUriPluginCollector` (as a value) under their original names. The subpath export `./registrar` SHALL also resolve to a module exposing the same two symbols. Neither symbol SHALL appear under a different name elsewhere — there is one canonical exit.
#### Scenario: root barrel re-export
- **WHEN** a caller imports both symbols from `@suaveplan/audio-ir-core`
- **THEN** both imports resolve AND `typeof createUriPluginCollector === "function"` AND the type import compiles
#### Scenario: subpath export resolves identically
- **WHEN** a caller imports both symbols from `@suaveplan/audio-ir-core/registrar`
- **THEN** the imports resolve to the same references returned by the root barrel
**Verified by:** `src/registrar/registrar.spec-conformance.test.ts::"root barrel re-exports MutableUriPluginRegistrar and createUriPluginCollector"` AND `src/registrar/registrar.spec-conformance.test.ts::"./registrar subpath resolves to the same references as the root barrel"`
<!-- markdownlint-enable MD024 -->