# Capability Spec Delta: media (media) **Package**: `@suaveplan/media` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: Stable error code registry The package SHALL expose a `MEDIA_CODES` constant created via `defineCodes("MEDIA", [...] as const)` containing exactly eight stable codes: `EMPTY_INPUT`, `INVALID_CONSTRAINTS`, `VALIDATION_FAILED`, `UNSUPPORTED_TYPE`, `PREDICATE_FAILED`, `DUPLICATE_SIGNATURE`, `METADATA_EXTRACTOR_FAILED`, `INVALID_SIGNATURE`. Each code MUST have a metadata record (`httpStatus`, `retryable`, `severity`, `description`) accessible via `MEDIA_ERROR_METADATA`. #### Scenario: registry shape is frozen - **WHEN** a consumer reads `MEDIA_CODES` - **THEN** the returned object MUST contain all eight expected keys and MUST be `Object.isFrozen`. #### Scenario: every code is namespaced - **WHEN** a consumer iterates `Object.values(MEDIA_CODES)` - **THEN** every value MUST start with the `MEDIA.` namespace prefix. **Verified by:** `src/errors/errors.test.ts::"contains all eight stable codes"` AND `src/errors/errors.test.ts::"registry is frozen"` AND `src/errors/errors.test.ts::"codes carry MEDIA namespace prefix"` --- ### Requirement: MediaError extends SuaveplanError The package SHALL export `MediaError extends SuaveplanError`. Constructing `new MediaError(MEDIA_CODES.EMPTY_INPUT, "msg")` MUST yield an instance whose `.code === "MEDIA.EMPTY_INPUT"`, whose `.message === "msg"`, and which satisfies both `instanceof MediaError` and `instanceof Error`. The class MUST expose a `.metadata` getter returning the matching record from `MEDIA_ERROR_METADATA`. #### Scenario: round-trips through cause chain - **WHEN** a `MediaError` is constructed with `cause: nativeErr` - **THEN** `error.cause` MUST return the original native error and `error.code` MUST preserve the namespaced code. #### Scenario: metadata getter resolves code - **WHEN** a consumer reads `new MediaError(MEDIA_CODES.UNSUPPORTED_TYPE, "x").metadata` - **THEN** the returned record MUST report `httpStatus: 415` and `severity: "warning"`. **Verified by:** `src/errors/errors.test.ts::"constructs with code, message, instanceof Error"` AND `src/errors/errors.test.ts::"preserves cause through chain"` AND `src/errors/errors.test.ts::"exposes metadata via getter"` --- ### Requirement: asMediaError lift helper The package SHALL expose `asMediaError(code, cause, fallback)` that wraps an unknown thrown value into a `MediaError`. When `cause instanceof Error` and its message is non-empty, the wrapped error's `.message` MUST equal the cause's message; otherwise it MUST equal the supplied `fallback`. `error.cause` MUST be an `Error` instance regardless of the cause's original shape. #### Scenario: preserves Error message and cause - **WHEN** `asMediaError(MEDIA_CODES.PREDICATE_FAILED, new Error("root"), "fb")` is invoked - **THEN** `.message` MUST be `"root"` and `.cause` MUST be the original Error. #### Scenario: falls back to default for non-Error cause - **WHEN** `asMediaError(MEDIA_CODES.PREDICATE_FAILED, "scalar", "fb")` is invoked - **THEN** `.message` MUST be `"fb"` and `.cause` MUST be a synthesised Error whose message is `"scalar"`. **Verified by:** `src/errors/errors.test.ts::"preserves Error cause when wrapping"` AND `src/errors/errors.test.ts::"falls back to default message when cause has none"` --- ### Requirement: Pluggable signature registry The package SHALL expose a `SignatureRegistry` class with `register`, `unregister`, `list`, `match`, and `detect` methods. `BUILTIN_SIGNATURES` MUST be a frozen array covering at minimum PNG, JPEG, GIF, BMP, WEBP, RIFF, MP4/MOV, WebM/MKV, MP3 (ID3 + sync), FLAC, and OGG. A process-wide `defaultSignatureRegistry` MUST be exposed, seeded with `BUILTIN_SIGNATURES`. #### Scenario: built-in registry detects PNG - **WHEN** `defaultSignatureRegistry.detect(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00]))` is invoked - **THEN** the result MUST be `"image"`. #### Scenario: signature offset is honoured - **WHEN** the registry is asked to detect MP4 bytes whose `ftyp` marker lives at offset 4 - **THEN** detection MUST return `"video"`. #### Scenario: register rejects duplicate label - **WHEN** a caller registers a second signature whose `label` matches an existing entry - **THEN** a `MediaError` with code `MEDIA.DUPLICATE_SIGNATURE` MUST be thrown. #### Scenario: register rejects empty bytes - **WHEN** a caller registers a signature with `bytes: []` - **THEN** a `MediaError` with code `MEDIA.INVALID_SIGNATURE` MUST be thrown. #### Scenario: register rejects non-byte values - **WHEN** a caller registers a signature whose `bytes` array contains a value outside `[0, 0xff]` or a non-integer - **THEN** a `MediaError` with code `MEDIA.INVALID_SIGNATURE` MUST be thrown. #### Scenario: unregister removes by label - **WHEN** a caller invokes `unregister("PNG")` - **THEN** the method MUST return `true` and subsequent `detect` of PNG bytes MUST return `"unknown"`. **Verified by:** `src/signatures/signatures.test.ts::"detects PNG via default registry"` AND `src/signatures/signatures.test.ts::"honours non-zero offset (MP4 ftyp)"` AND `src/signatures/signatures.test.ts::"rejects duplicate label"` AND `src/signatures/signatures.test.ts::"register rejects empty byte array"` AND `src/signatures/signatures.test.ts::"register rejects non-byte values"` AND `src/signatures/signatures.test.ts::"unregister removes signature and returns true"` --- ### Requirement: Byte and filename detection The package SHALL expose `detectMediaType(input, opts?)`, `detectSignature(input, opts?)`, and `detectMediaTypeFromName(filename, opts?)`. The bytes-based helpers MUST delegate to `opts.registry ?? defaultSignatureRegistry`. The filename helper MUST call `getMimeType` from `@suaveplan/mime` and bucket the result by top-level type prefix (`image/`, `video/`, `audio/`). #### Scenario: PNG bytes route to image - **WHEN** `detectMediaType` is invoked with PNG magic bytes - **THEN** the result MUST be `"image"`. #### Scenario: empty input is detected as unknown - **WHEN** `detectMediaType(new Uint8Array([]))` is invoked - **THEN** the result MUST be `"unknown"`. #### Scenario: filename .mp4 routes to video - **WHEN** `detectMediaTypeFromName("clip.mp4")` is invoked - **THEN** the result MUST be `"video"`. **Verified by:** `src/detection/detection.test.ts::"detects PNG from magic bytes"` AND `src/detection/detection.test.ts::"returns unknown for empty input"` AND `src/detection/detection.test.ts::"detects video from .mp4 filename"` --- ### Requirement: Canonical MIME inference The package SHALL expose `inferMimeFromSignature(sig)` and `defaultMimeForType(type)`. `inferMimeFromSignature` MUST return the canonical MIME for each built-in label (PNG → `image/png`, JPEG → `image/jpeg`, MP4/MOV → `video/mp4`, WebM/MKV → `video/webm`, FLAC → `audio/flac`, OGG → `audio/ogg`, MP3-ID3 / MP3-sync → `audio/mpeg`). For unknown labels it MUST fall back to `defaultMimeForType(sig.type)`. For a `null` signature it MUST return `"application/octet-stream"`. #### Scenario: PNG signature maps to image/png - **WHEN** `inferMimeFromSignature` is called with a PNG signature record - **THEN** the result MUST be `"image/png"`. #### Scenario: null signature falls back to octet-stream - **WHEN** `inferMimeFromSignature(null)` is invoked - **THEN** the result MUST be `"application/octet-stream"`. **Verified by:** `src/detection/detection.test.ts::"returns canonical MIME for PNG signature"` AND `src/detection/detection.test.ts::"returns octet-stream for null signature"` --- ### Requirement: Metadata extraction with extractor hook The package SHALL expose `getMetadata(input, opts?)` and `getMetadataAsync(input, opts?)`. Both MUST return a `MediaMetadata` bundle populated with `type`, `mimeType`, `size`, and (when a signature matched) `signatureLabel`. When `opts.filename` is supplied, the MIME MUST be resolved via `getMimeType(filename)`. The async variant MUST accept a `MediaMetadataExtractor` and merge its return value over the base bundle. #### Scenario: getMetadata reports type, mime, size, label - **WHEN** `getMetadata(pngBytes, { filename: "x.png" })` is invoked - **THEN** the returned bundle MUST satisfy `type === "image"`, `mimeType === "image/png"`, `size === pngBytes.byteLength`, `signatureLabel === "PNG"`. #### Scenario: extractor merges async fields - **WHEN** `getMetadataAsync` is invoked with an extractor that returns `{ width: 100, height: 50 }` - **THEN** the returned bundle MUST satisfy `width === 100` and `height === 50`. #### Scenario: extractor exception is wrapped - **WHEN** the supplied extractor throws - **THEN** `getMetadataAsync` MUST reject with a `MediaError` whose code is `MEDIA.METADATA_EXTRACTOR_FAILED`, preserving the original error as `.cause`. **Verified by:** `src/metadata/metadata.test.ts::"returns type, mime, size, label for PNG bytes"` AND `src/metadata/metadata.test.ts::"merges extractor output"` AND `src/metadata/metadata.test.ts::"wraps extractor error in MediaError"` --- ### Requirement: Zod-validated constraints `MediaConstraintsSchema` SHALL validate every constraint field. Malformed constraints supplied to `validateMedia` or `validateMediaAsync` MUST cause the call to throw `MediaError(MEDIA.INVALID_CONSTRAINTS)` unless the caller opts out via `skipConstraintValidation: true`. #### Scenario: negative maxSize is rejected - **WHEN** `validateMedia(input, { maxSize: -1 })` is invoked without `skipConstraintValidation` - **THEN** a `MediaError` with code `MEDIA.INVALID_CONSTRAINTS` MUST be thrown. #### Scenario: skipConstraintValidation bypasses Zod - **WHEN** `validateMedia(input, badConstraints, { skipConstraintValidation: true })` is invoked - **THEN** the call MUST NOT throw `MEDIA.INVALID_CONSTRAINTS`. **Verified by:** `src/validation/validation.test.ts::"throws INVALID_CONSTRAINTS for malformed input"` AND `src/validation/validation.test.ts::"skips Zod check when skipConstraintValidation: true"` --- ### Requirement: Synchronous validation `validateMedia(input, constraints, opts?)` SHALL run the empty-input guard, build the metadata bundle, and apply every constraint check (`minSize`, `maxSize`, `allowedTypes`, `allowedMimeTypes`, `maxWidth`, `minWidth`, `maxHeight`, `minHeight`, `maxDuration`). It MUST return a `MediaResult` whose `valid` flag is `errors.length === 0`. Empty input MUST throw `MediaError(MEDIA.EMPTY_INPUT)`. #### Scenario: compliant input returns valid result - **WHEN** `validateMedia(pngBytes, { maxSize: 1024, allowedTypes: ["image"] })` is invoked - **THEN** the result MUST satisfy `valid === true` and `errors.length === 0`. #### Scenario: empty input throws MEDIA.EMPTY_INPUT - **WHEN** `validateMedia(new Uint8Array([]), {})` is invoked - **THEN** a `MediaError` with code `MEDIA.EMPTY_INPUT` MUST be thrown. #### Scenario: failures are reported per rule - **WHEN** `validateMedia(pngBytes, { allowedTypes: ["video"], allowedMimeTypes: ["image/jpeg"] })` is invoked - **THEN** `errors` MUST contain entries for both the unsupported type and unsupported MIME. **Verified by:** `src/validation/validation.test.ts::"passes when all constraints met"` AND `src/validation/validation.test.ts::"rejects empty input with MediaError"` AND `src/validation/validation.test.ts::"fails when type not in allowedTypes"` AND `src/validation/validation.test.ts::"fails when MIME not in allowedMimeTypes"` --- ### Requirement: Asynchronous validation with predicate `validateMediaAsync` SHALL behave identically to `validateMedia` but MUST also run `constraints.predicate(metadata, input)` when present. A `string` return value MUST be appended to the `errors` array. A `null` / `undefined` return MUST pass. A thrown exception MUST surface as `MediaError(MEDIA.PREDICATE_FAILED)` with the original error chained. #### Scenario: predicate returning string contributes error - **WHEN** `validateMediaAsync(input, { predicate: () => "rule failed" })` is invoked - **THEN** the result MUST satisfy `valid === false` and `errors` MUST include `"rule failed"`. #### Scenario: predicate exception wraps in MEDIA.PREDICATE_FAILED - **WHEN** the supplied predicate throws - **THEN** the call MUST reject with `MediaError(MEDIA.PREDICATE_FAILED)`, preserving the original error as `.cause`. **Verified by:** `src/validation/validation.test.ts::"runs predicate and reports its error string"` AND `src/validation/validation.test.ts::"predicate throwing yields PREDICATE_FAILED"` --- ### Requirement: assertValidMedia throwing variant `assertValidMedia(input, constraints, opts?)` SHALL invoke `validateMedia` and return the resulting `MediaMetadata` when `result.valid === true`. When any error is reported it MUST throw `MediaError(MEDIA.VALIDATION_FAILED)` whose message contains the joined errors. #### Scenario: returns metadata on success - **WHEN** `assertValidMedia(pngBytes, { allowedTypes: ["image"] })` is invoked - **THEN** the returned bundle's `type` MUST be `"image"`. #### Scenario: throws on failure - **WHEN** `assertValidMedia(pngBytes, { allowedTypes: ["video"] })` is invoked - **THEN** a `MediaError` with code `MEDIA.VALIDATION_FAILED` MUST be thrown. **Verified by:** `src/validation/validation.test.ts::"returns metadata on success"` AND `src/validation/validation.test.ts::"throws VALIDATION_FAILED on failure"` --- ### Requirement: Observability bundle factory The package SHALL expose `createMediaObservability(opts?)` returning a `MediaObservability` bundle with `meter`, `tracer`, four counters (`detectTotal`, `metadataTotal`, `validateTotal`, `errorTotal`), and two histograms (`opLatencyMs`, `inputSizeBytes`). When `opts.observability` is supplied the function MUST short-circuit and return the same instance. #### Scenario: factory produces complete bundle - **WHEN** `createMediaObservability()` is invoked with no options - **THEN** the returned bundle MUST include `meter`, `tracer`, every counter, and every histogram. #### Scenario: pre-built bundle short-circuits - **WHEN** `createMediaObservability({ observability: existing })` is invoked - **THEN** the returned bundle MUST be the same reference as `existing`. #### Scenario: injected meter is preserved - **WHEN** `createMediaObservability({ meter })` is invoked with a custom meter - **THEN** the returned bundle's `.meter` MUST be the injected instance. **Verified by:** `src/observability/observability.test.ts::"returns a fully-populated bundle with global providers by default"` AND `src/observability/observability.test.ts::"short-circuits when a pre-built observability bundle is given"` AND `src/observability/observability.test.ts::"uses an injected meter when supplied"`