Binds api names to {store, ref} plus per-API config, in ETS. No
persistence: hosts re-register at boot from wherever their specs live.
A registration holds no spec bytes of its own — the one exception being
the private store below, which is a store the registry is, not bytes a
registration holds. It reads the OapiCodemode.SpecStore
exactly twice, both at registration — meta/2, which is where a missing
ApiConfig.base_url falls back to the document's default server and
where tool descriptions get title and tags, and index/2, the slim
search index — and caches only what every later call needs. Everything
else (an operation, its schemas) is read from the store per call, against
the ref this registration was built on, so a newer projection of the
same document cannot change what a running loop sees: a ref is frozen for
whoever holds it.
One qualification, and it applies to the private store below and to
nothing else. That store is one the registry is, not one a registration
merely names, so the registry also frees a projection no live binding
references any more — on re-registration, and on
collect_unreferenced/2. A run still holding a ref that a concurrent
re-registration just unbound therefore reads a projection that may be
gone: its next describe or resolve fails cleanly (:not_found, surfaced
as a describe error payload or the proxy's :resolve phase) rather than
answering out of the new projection. Never a wrong answer, occasionally
a lost one. A host's own store is never collected, so a ref into it stays
readable for as long as the host keeps it.
Rows are {api_name, {store, ref}, %ApiConfig{}, cache}, where the cache
is the index twice over — as %OperationSummary{}s for the matcher, and
pre-encoded as JSON for the sandbox — plus the handful of meta/2 and
config values the per-call paths read. sandbox_meta/1 projects the three
fields the search hot path wants out of it, rather than copying an index
of thousands of summaries per tool call.
The store reads happen in the caller, not in the registry process: a
host registering fifty APIs at boot against a database-backed store
should not queue fifty round trips behind one mailbox. Only the ETS write
is a GenServer.call, because the table is :protected.
Reads pay one GenServer.call to fetch the table ref — deliberate: lookups happen a handful of times per LLM tool call, so the hop is noise next to the LLM turn and the upstream HTTP request, and it keeps unnamed per-test registries isolated (a :named_table would not).
Summary
Functions
Returns a specification to start this module under a supervisor.
Frees ref in the private store if no live binding references it.
The library-private store OapiCodemode.ingest_and_register/4 puts into.
Register the spec at {store, ref} under api_name.
Projected read for the per-call hot paths (I3): each API's slim search
index pre-encoded to JSON at registration — a refc binary, so reads share
it rather than copy it — plus its model-visible sandbox_globals. Sorted
by API name.
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec collect_unreferenced(GenServer.server(), OapiCodemode.SpecStore.ref()) :: :ok
Frees ref in the private store if no live binding references it.
For the one caller that can strand a projection: a
OapiCodemode.ingest_and_register/4 whose put landed and whose
registration was then refused. The put wrote a whole projection nothing
will ever name, and without this it would sit in the table for the life of
the registry.
The check and the delete both run in the registry process, so a registration racing this call either lands first — and its binding is seen, and nothing is freed — or lands after, on a ref this call has already decided about. A ref in any other store, or a ref some name still binds, is left alone.
@spec list(GenServer.server()) :: [{String.t(), OapiCodemode.Registry.Entry.t()}]
@spec lookup(GenServer.server(), String.t()) :: {:ok, OapiCodemode.Registry.Entry.t()} | {:error, :unknown_api}
@spec private_store(GenServer.server()) :: OapiCodemode.SpecStore.store()
The library-private store OapiCodemode.ingest_and_register/4 puts into.
One ETS store per registry, created on first use and owned by the registry process, so a host with one small spec and no store of its own keeps its one-call registration: ingest, put, register. Hosts that have a store of their own never touch it.
This handle is collectible
The registry frees projections in this store that no live binding
references (see the moduledoc). A ref held anywhere the registry cannot
see it — put directly and kept by the caller, registered into a
second registry, held across a re-registration of the name it was
bound to — can therefore start answering {:error, :not_found}. A host
that needs a store with a retention policy of its own should make one
(OapiCodemode.SpecStore.ETS.new/0) rather than borrow this one.
@spec register( GenServer.server(), String.t(), {OapiCodemode.SpecStore.store(), OapiCodemode.SpecStore.ref()}, OapiCodemode.ApiConfig.t() ) :: :ok | {:error, term()}
Register the spec at {store, ref} under api_name.
api_name must be a valid JS identifier — it becomes a property name on
the sandbox globals. Refusals:
{:error, {:invalid_api_name, name}}— not a JS identifier.{:error, {:invalid_idempotency_header, name}}—auto_idempotency_headeris not a usable header name (empty, non-binary, or one of the reserved names the library and credential layer own).{:error, {:unknown_decomposer_version, version}}— therefnames a projection this library did not derive. Fail closed: mid-deploy, the node running the old decomposer must not read a projection built by the new one as if it understood it.{:error, :no_base_url}— neither the config nor the document supplies a server URL.{:error, :index_too_large}— the search index alone exceeds 8 MB.{:error, {:index_bytes_mismatch, spliced, measured}}— the index the store handed back does not encode to theindex_bytesits ownmeta/2reported. The two numbers are the same bytes measured at either end of the store, so a disagreement means the store is not returning the index it was given, and the cap above would be guarding a number nobody sees.
Store errors ({:error, :not_found} for a vanished projection, anything
else the implementation raises as a tuple) pass through unread.
@spec sandbox_meta(GenServer.server()) :: [ {String.t(), %{ index_json: String.t(), sandbox_globals: map(), auto_idempotency_header: String.t() | nil }} ]
Projected read for the per-call hot paths (I3): each API's slim search
index pre-encoded to JSON at registration — a refc binary, so reads share
it rather than copy it — plus its model-visible sandbox_globals. Sorted
by API name.
Unlike list/1 this reads three fields out of the cached map instead of
the map itself, so a tool call does not copy an index of thousands of
%OperationSummary{}s out of ETS to splice one binary.