0.5.0 — 2026-08-30

Specs are now stored decomposed and read lazily. Ingest no longer dereferences a whole document into one artifact the registry holds: it takes the document apart — canonical evidence, one row per operation, one per component, every payload $ref left where the document wrote it — and a registration binds the {store, ref} those pieces live at, reading one operation's refs per call. The same change reaches the sandbox: instead of a dereferenced document spliced into a search run, search_apis ships a slim operation index (id, method, path, summary, tags, parameter names) and the model calls specs.<api>.describe(id | [ids]) for the schemas of the few operations it picked — a metered host round trip into the store. A large spec therefore costs a run its index rather than its document, and a host that keeps specs in a database of its own implements one behaviour instead of caching artifacts.

Added

  • OapiCodemode.SpecStore: the behaviour between the library and wherever specs actually live. A store is {module, handle}, a ref is {spec_id, decomposer_version} naming one projection of one document, and the six callbacks are put/2, meta/2, index/2, operation/3, components/3 (bulk, one call per breadth-first level of a resolve) and pointer/4 (the slow path, byte-bounded). Module-level functions mirror the callbacks so callers hold a store instead of unpacking tuples. %SpecStore.Meta{} is what a registration reads once; %SpecStore.OperationSummary{} is one slim-index entry.
  • OapiCodemode.SpecStore.ETS: the implementation the library ships with — one :set table, new/0 for the handle, rows gated on a {:meta, ref} commit marker written last so a projection mid-write is invisible rather than half-visible, and delete_projection/2 to free one.
  • OapiCodemode.SpecStoreCase: the conformance suite, shipped in lib/ so a host's own store is held to the same contract — use OapiCodemode.SpecStoreCase and supply store_fixture/0. It covers the decompose → put → meta/index/operation/components round trip, put of a known hash, two decomposer versions coexisting, concurrent put, pointer/4's byte budget and array walk, absent component names, :not_found for an unknown ref or id, and index order.
  • OapiCodemode.Resolve: read-time $ref resolution — one stored operation row plus the store in, one usable operation out. A breadth-first fetch pass (a whole layer of refs per components/3 call) followed by a depth-first substitute pass, so output is byte-identical to the whole-document expansion 0.4.0 shipped; for: :request resolves parameters and the request body, for: :describe also responses; max_bytes: bounds the resolved closure. Unfollowable refs stay readable as data: %{"$circular" => name}, %{"$unresolved" => ref}, and now %{"$truncated" => true} for a subtree that did not fit the budget.
  • OapiCodemode.Pointer: same-document JSON pointer resolution in one place (Ingest, Deref and a store's pointer/4 all need the same answer), with escape/1 and unescape/1. resolve/3 takes arrays: true to walk numeric array-index tokens per RFC 6901 — off by default, so every existing caller keeps the maps-only 0.4.0 walk.
  • OapiCodemode.Ingest.decompose/1 and %OapiCodemode.Decomposed{}: take a document apart into canonical evidence, the document's own fields, this decomposer's projection, one entry per operation and one per component, inlining nothing. Read-time resolution is the caller's job.
  • OapiCodemode.Ingest.decomposer_version/0 and OapiCodemode.Ingest.component_kinds/0: the version a projection is stored under, and the component kinds a store therefore holds (everything standard except examples).
  • OapiCodemode.Ingest.index_entry/1: the single definition of a slim-index entry. The projection's index_bytes is the encoded size of exactly these entries, so an index builder must go through it rather than rebuild the shape.
  • OapiCodemode.Ingest.Normalize.extract_body/1 and parse_segments/1 are now public. The first means read-time resolution of a $ref'd requestBody reaches the same %{"required", "content_type", "schema"} shape as an inline body by construction; the second lets an %OperationSummary{} derive its match segments without persisting a second spelling of path.
  • OapiCodemode.Registry.private_store/1: the library-private SpecStore.ETS store ingest_and_register/4 puts into, owned by the registry process. Exposed so a host can see what the convenience path did; see the warning under Changed before holding a ref into it.
  • OapiCodemode.Registry.collect_unreferenced/2: frees a projection in that private store which no live binding names. ingest_and_register/4 calls it on the one path that can strand one — the put landed and register/4 then refused (a name that is not a JS identifier, an index too large, a bad idempotency header) — so a refusal leaves the store exactly as it found it.
  • OapiCodemode.Proxy.Matcher.nearest_ids/2: the five operation ids nearest a given one by Jaro distance, so describe can name the near miss an unknown id usually is (the same self-correction the matcher already offers a request that matches no route).
  • OapiCodemode.Tools.describe_limits/0: the budgets a search run's describe is held to — 10 ids per call, 512 KB of resolved JSON per run. Public so the tool description states the numbers the callback enforces rather than a second copy of them.
  • ApiConfig.max_resolve_bytes (default 5_000_000): a cap on the spec bytes one request may resolve. Sibling of max_response_bytes, which bounds what comes back from the API; this bounds what comes back from the spec store. Each stored row is capped at 1 MB, but a request resolves an operation's whole transitive $ref closure and nothing else capped that aggregate. Over-budget subtrees arrive as {"$truncated": true}, which validation reads as "no schema here" and lets through — a guardrail against a pathological spec, not a reason to refuse the model's request.
  • A :resolve phase for [:oapi_codemode, :request, :error] telemetry: the spec store failing to hand back an operation the index lists, or handing back one that disagrees with it. Our storage, not the model's request, so the message crossing back to the sandbox is fixed and the store's own reason goes to Logger.

Changed

  • Breaking: OapiCodemode.register/4 binds a store, not an artifact. It takes {store, ref} — a OapiCodemode.SpecStore module/handle pair and the {spec_id, decomposer_version} a put/2 returned — and the registry holds that binding plus the runtime ApiConfig and a small cache (the slim index, pre-encoded for the sandbox, plus title, tags and security schemes). No spec bytes live in the registry, with one exception: ingest_and_register/4 puts into the library-private ETS store the registry process owns, and those bytes are a store's, not a registration's — re-registering an api name through it frees the projection that name superseded. Registration reads the store exactly twice (meta/2, index/2) and refuses, rather than degrading, when: neither the config nor the document names a base URL (:no_base_url), the slim index exceeds 8 MB (:index_too_large), the ref names a decomposer_version this library did not derive ({:unknown_decomposer_version, version}), or the store's index/2 does not encode to the index_bytes its meta/2 reported ({:index_bytes_mismatch, spliced, reported}).
  • Breaking: OapiCodemode.ingest/1 returns %OapiCodemode.Decomposed{}. It delegates to Ingest.decompose/1. ingest_and_register/4 is unchanged from the outside: it decomposes and puts into the library-private ETS store, then registers the resulting binding.
  • Breaking: the executor callback contract is named host callbacks. env.callbacks is a map of name to an arity-1 function over the JSON-decoded argument list (fn [api_name, opts] -> ... end, was fn api_name, opts -> ... end), and an executor exposes each one to the guest as host.<name>(...args):request for execute runs, :describe for search runs. The apis.<name>.request(opts) sugar the model is taught is unchanged; it now forwards through host.request. A custom executor must build the host object and switch its callback invocation to the list form; a host testing with Executor.Mock calls env.callbacks.request.([name, opts]).
  • Breaking: search_apis's specs.<api> global is {operations, describe}, not the dereferenced document. specs.<api>.operations is the slim index — an array of {id, method, path, summary, tags, params}, no schemas — and await specs.<api>.describe(id | [ids]) resolves the operations the model picked in full (parameter schemas, request body, responses, security), reading the spec store per call rather than shipping a document into the sandbox. Sandbox code that walked specs.<api>.paths must iterate specs.<api>.operations instead. describe is metered: at most 10 ids per call, one call charged per id against :max_calls, and 512 KB of resolved JSON per search run, after which it answers {id, error: "describe budget exhausted"}. Refusals — an unknown id (which names the nearest ids), a batch too big, a spent budget, an API outside the call's :api_allowlist — are data in the result, never raised errors, and an array of ids always answers an array of the same length in the same order, one entry per id: a batch over the cap refuses per id rather than handing back a bare error object a .map() would trip over. The numbers are OapiCodemode.Tools.describe_limits/0, which the tool description reads so it states what the callback enforces (the worked example included, which slices its batch to the cap). Metering comes first: a run that has spent its calls or its bytes refuses further describes before touching the registry, so a loop of them costs no lookups.
  • Operation gained description, responses, deprecated and param_index. request_body is now a union: either a $ref map kept verbatim for lazy resolution, or the extracted media-type shape.
  • param_index is filtered where the row is built, not where the index is projected: a parameter whose identity could not be resolved — a dangling $ref, a chain past the hop limit — has no entry, so param_index is exactly what the slim index publishes and Ingest.index_entry/1 is a pure projection of the operation row. A store persists param_index and hands it back; it decides nothing about it. It is therefore no longer positionally aligned with parameters, which still carries every $ref verbatim. OapiCodemode.SpecStoreCase pins both ends.
  • Behaviour change on malformed input. Decomposition applies Normalize's scalar coercions throughout, which slightly changes what ingesting does with a document that lies about its types: a non-string operationId is treated as absent (the id is derived instead of being carried through), a non-string summary or description becomes nil rather than the raw value, non-string elements of tags are dropped, and a non-list security is coerced rather than passed along. Well-formed documents are unaffected.
  • The proxy's per-request store read is bounded, ordered and cross-checked. Both policy checks now run before the read — the read-only refusal needs only the method, which the index entry carries, so a request this tool may not make costs zero store reads — the resolved closure is bounded by max_resolve_bytes, and the resolved operation's path is checked against the index entry's. The matcher bound path params against the index template, so a store that disagreed would have those bindings substituted into a different one: a credentialed request to a URL the model never named. That is a :resolve-phase error, with the disagreement logged.
  • The proxy passes Resolve's plain atom-keyed map downstream rather than struct!-ing it into an %Operation{}: a stored operation holds $refs, a resolved one holds markers and, under for: :request, no responses key at all, where the struct would have defaulted it to nil and made "we did not look" indistinguishable from "the document declared none". Proxy.Validator and Proxy.Matcher take either shape.
  • OapiCodemode.Registry.private_store/1's handle is documented as collectible. The registry frees projections in that store which no live binding names, so a ref held where the registry cannot see it — put directly and kept, registered into a second registry, held across a re-registration of the name it was bound to — can start answering {:error, :not_found}. A stale read, never a wrong one. Hosts that want a retention policy of their own make their own SpecStore.ETS.
  • OapiCodemode.SpecStore.OperationSummary carries segments, derived from path (it is not part of the JSON entry), so the proxy can match against the registry's cached index and resolve only the one operation it is about to send.

Removed

  • Breaking: OapiCodemode.Artifact and the whole-document ingest/1 pipeline. Nothing dereferences a whole document at ingest any more: decomposition stores the pieces and OapiCodemode.Resolve follows the refs of the one operation being read. OapiCodemode.Ingest.Deref survives as the parity oracle the resolve suite checks lazy resolution against — tests only, not library code.