Skip to content
4 min read · 793 words

The Capability Model ​

Every capability with more than one viable implementation is a contract, not a dependency. You compose the implementation when you build the pipeline, the choice is visible at the call site, and swapping it later touches one line. Nothing is ever auto-selected — that's the "bring your own everything" rule applied to media processing, and it is not negotiable here either. A library that picks your OCR engine for you has also picked your WASM payload, your model downloads, your licensing exposure, and your failure modes. We'd rather hand you a table of trade-offs and make you read it — that table lives on The Bundled Fleet.

Three capabilities. That's the whole taxonomy. ​

A media engine does exactly three things, and the contract refuses to pretend otherwise:

  • Convert — change the format. docx → pdf. And once you see it that way, a lot of things that usually get bespoke interfaces turn out to be conversions wearing costumes: OCR is image/* → txt. Transcription is pcm → txt. Decoding audio is audio/* → pcm. Extracting a PDF's embedded images is pdf → images (multi-output). And generating a new file is a conversion whose source format happens to be nothing — EMPTY_MIME → xlsx (see Generating Media). One declaration shape covers all of them.
  • Mutate — change the content, keep the family. Resize, rotate, flip, strip metadata — with an optional re-encode riding the same call, because making a fused resize-and-convert cost two decode/encode cycles to satisfy a taxonomy would be vandalism.
  • Edit — restructure the document. Insert a worksheet row, rename a sheet, splice columns. One named op per request.

Why mutate and edit are different capabilities (and not one)

The obvious "simplification" — one mutation capability for both pixels and worksheets — was considered and rejected, because the two shapes exist for opposite reasons:

Mutate exists for fusion. Adjacent image transforms (image resize | image rotate | image format) fold into ONE engine request with fixed fields (resize, rotate, flip, format), because every image op pays the same decode/encode toll and paying it once instead of three times is the whole point. The request shape is closed and image-shaped on purpose — fusion only works when the engine sees all the folded ops at once.

Edit exists for fidelity arbitration. A worksheet splice is not a pixel pass: there's no decode/encode to amortize, so edits run one named op per request with open, verb-shaped args ({ op: 'sheet.update_cells', args }). What edits need instead is something mutate never needed — two engines truthfully declaring the same ops while differing in what they preserve. ExcelJS keeps fonts, fills, and comments through an edit; SheetJS CE strips them. Same declared capability, different fidelity, and the registry doesn't rank fidelity — your engines-array order does. That arbitration is the fleet page's two-spreadsheet-engines story.

Contorting worksheet ops into the fused image request (or un-fusing image ops into named requests) would have cost one of the two properties. Two shapes, each earning its keep.

Declarations are plain data ​

An engine declares what it can do as plain data — the registry never has to call engine code to know the shape of the world:

typescript
import type { MediaEngine } from '@nhtio/adk/batteries/media/contracts'

const myEngine: MediaEngine = {
  id: 'my-engine', // shows up in error messages, so name it honestly
  converts: [
    { from: ['application/pdf'], to: ['txt', 'html'], convert: async (request) => ({ outputs: [/* … */] }) },
  ],
  mutates: [
    { over: ['image/*'], ops: ['resize', 'rotate'], encodes: ['png', 'jpg'], mutate: async (request) => ({ /* … */ }) },
  ],
  edits: [
    { over: ['application/vnd…spreadsheetml.sheet'], ops: ['sheet.update_cells'], edit: async (request) => ({ /* … */ }) },
  ],
}

A new capability is a new edge in the data — never a new contract, never a new slot, never a registry PR. Plain data is also the only thing the registry trusts: it reads declarations to decide what a deployment can do, and it never calls into an engine to find out. An engine that lies in its declarations gets caught the moment it's asked to deliver, not before — which is exactly why the declarations are validated and the outputs are validated and the types are treated as the least of the three.

The guard is the contract: TypeScript types are a courtesy; construction duck-checks every engine and every declared capability entry, and a malformed one fails loud with the index named — engines[1] ("sharp") does not implement the MediaEngine contract — never silently mid-chain. A misbehaving engine's output is also validated at the step boundary, so a BYO engine that returns garbage produces a clear error pointing at the engine — not corrupt media three steps later with your name on it. Loud at construction, loud at the boundary, never quietly wrong in between: the failure you can't see is the only kind this contract is afraid of.