# 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 `-` (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`. 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-` 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-` 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"`