Caveman middleware protocol 1.1
Status: normative. The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are used as described in RFC 2119 and RFC 8174.
This document specifies the HTTP protocol between Caveman framework middleware
clients (the @caveman-ai/sdk/middleware and caveman_cloud.middleware SDKs
and the adapters built on them) and a Caveman middleware runtime
(caveman-proxy). Machine-readable artifacts:
| Artifact | Path |
|---|---|
| JSON Schemas | packages/shared/contracts/schemas/middleware-*.schema.json |
| OpenAPI 3.1 | packages/shared/contracts/openapi/middleware.openapi.json |
| Parity vectors (1.0) | packages/sdk/parity/middleware.fixtures.json |
| Parity vectors (1.1) | packages/sdk/parity/middleware-v1_1.fixtures.json |
Where this prose and a fixture disagree, the fixture is the bug report: fix one of them in the same change.
Versions. Protocol 1.0 is what shipped in runtimes up to bin-v1.1.7, SDK 1.1.0
(TS and Python) and middleware 0.1.0-alpha.2 / 0.1.0a1. Protocol 1.1 ships
in runtime bin-v2.0.0, SDK 1.2.0 and middleware 1.0.0. It is additive:
schema_version stays 1 on every body, and 1.1 behavior is negotiated per
request (§3).
1. Routes and transport basics
A client is configured with a base URL B: scheme://host[:port][/prefix].
Every route URL is B + /caveman/v1/middleware/ + route. A prefix is
preserved (§15).
| Route | Method | Request schema | 200 body schema |
|---|---|---|---|
capabilities | GET | none | middleware-capabilities |
optimize | POST | middleware-optimize | middleware-plan |
retrieve | POST | middleware-retrieve | middleware-page |
receipts | POST | middleware-receipt | middleware-receipt-response |
sessions/delete | POST | middleware-session-delete | middleware-session-delete-response |
The receipt route is receipts (plural) on the wire.
- POST bodies MUST be a single JSON object sent as
Content-Type: application/jsonin UTF-8. The server answers any other content type with 400invalid_request. - Every response body is JSON. Servers MUST send
Cache-Control: no-storeandX-Content-Type-Options: nosniff. - Unknown routes, and methods other than those listed, get 404
not_found. Routing comes first after authentication: an unknown route or method is 404 before the server takes a quota count or a queue slot, or reads or checks the body (atext/plainPOST to an unknown route is 404, not 400). Exception: a caveman-proxy with no middleware store, or whose middleware failed to start, answers every path under/caveman/v1/middleware/, unknown routes included, with 503runtime_unavailableandRetry-After: 1before browser refusal and authentication run. - Every POST body carries
schema_version. A value outsideprotocolis 400unsupported_versionon every route,sessions/deleteincluded. The required-field check runs first: a body missing a required field is 400invalid_requestwhatever itsschema_version, soschema_version: 2with an incomplete body getsinvalid_request, notunsupported_version. - Object keys are case-sensitive. A body containing a key that differs only by
case from a field the schema defines at that position (for example
SEQUENCEnext to, or instead of,sequence) is 400invalid_request. This holds with and without the tolerant reader (§4): such a key is never "unknown", because a case-insensitive decoder would silently read it as the defined field. - Clients MUST NOT follow redirects (outcome
redirect_refused) and MUST stop reading a response body after 4 MiB (outcomepayload_limit).
2. Authentication and scope model
Browser refusal. A request carrying an Origin header, or
Sec-Fetch-Site: cross-site, MUST get 403 forbidden_origin before
authentication runs.
Authentication. Every route, capabilities included, MUST authenticate.
The credential is Authorization: Bearer <credential> (a static token or an
OIDC JWT) or a TLS client certificate. It is a runtime credential. Clients MUST
NOT send a model provider API key. A missing or unknown credential gets 401
unauthorized, and servers SHOULD add WWW-Authenticate: Bearer. The server
resolves a principal from the credential only, never from request JSON. The
legacy single CAVEMAN_AUTH_TOKEN maps to one principal, single_operator,
which may use every namespace.
Namespace authorization. If the principal has an allowed-namespace list and
scope.namespace is not in it, the server MUST answer 403
forbidden_namespace.
Scope. scope = {namespace, session_id, branch_id, cache_epoch}. Each
member is a token matching ^[A-Za-z0-9._:/-]{1,256}$. Define
H(p1, …, pn) as the lowercase hex SHA-256 of the UTF-8 JSON array
[p1,…,pn]. Then:
authority = H(principal, namespace, session_id, branch_id, cache_epoch). Grants, handles, choices and originals belong to exactly one authority. A request can only reach what its own authority owns. An unknown handle, or one owned by another authority, gets 404not_found, so the response never reveals whether a foreign handle exists.scope_id = H(authority, adapter.id, adapter.serialization_revision).scope_idMUST NOT include the policy revision or the requested transform list (§5). Persisted choices therefore survive runtime upgrades and keep replacement bytes stable. Protocol 1.0 derivedscope_idfrom both, so the first 1.1 runtime deployment resets persisted choices once: each live session sees one provider-cache miss.- A persisted choice for
(scope_id, segment.id, segment.source_id, segment.sha256)MUST be reused byte for byte when itstransform_idis inrequest.policy.transforms. Otherwise the segment is skipped withunknown_capability. - Recovery handles match
^cmw_[a-f0-9]{48}$(24 random bytes).
3. Negotiation (K2)
Server → client. Capabilities MAY carry protocol: {min, max}, the
inclusive range of schema_version majors the server accepts (1.1: {1, 1}),
and features: [feature]. A document with no features array is a
protocol 1.0 runtime. Against such a runtime a client MUST send exact 1.0
shapes only.
Client → server. Clients MUST send Caveman-Middleware-Features on every
request, capabilities included. The value is a comma-separated list of
feature tokens. The 1.1 SDKs send exactly http_status_v2, revision_tolerant.
Servers split on ,, trim optional whitespace, and ignore empty, malformed
(^[a-z][a-z0-9_]{0,63}$) and unknown tokens. A request without the header is a
protocol 1.0 client.
Rule. A server applies the 1.1 semantics of feature F to a request only
when F is in both the request header and the server's own features.
Otherwise it MUST behave exactly as protocol 1.0 for that aspect. The two
server-advertised features are the exception: a request that carries the
header at all is a 1.1 client, so tolerant_reader and originals_lifecycle
apply to it whenever the server advertises them.
| Feature | Direction | Meaning when active |
|---|---|---|
tolerant_reader | server advertises | Server ignores unknown request fields (§4). Future optional request fields are sent only to servers advertising it. |
revision_tolerant | both | Revision acceptance per §5. |
http_status_v2 | both | Status mapping per §6 (decisions are 200; 408/429/503 + Retry-After). |
originals_lifecycle | server advertises | Delete and retention cover originals, and max_retention_seconds is enforced (§12). |
A 1.1 server advertises all four and protocol: {min: 1, max: 1}.
Legacy view of capabilities. A capabilities request without the features header MUST get a document that SDK 1.1.0 accepts. Such a document:
- omits every transform whose
recoveryis notexact_ccrornone, or whosedeterministicis nottrue; - has only positive safe integers in
limits; - carries a
policy_revisioncomputed over exactly the transforms it lists.
Additive top-level fields (protocol, features, max_retention_seconds) MAY
appear in both views, because 1.0 clients ignore unknown top-level fields.
4. Tolerant readers (K1)
Servers
- MUST ignore unknown request body fields at any depth and unknown headers. A key that matches a defined field case-insensitively but not exactly is not unknown; it is rejected (§1).
- MUST still require every required field and validate the known ones.
- MUST NOT give an unknown field any meaning. Anything security-relevant is a negotiated feature.
- Runtimes before
bin-v2.0.0reject unknown fields (DisallowUnknownFields), which is why clients gate new body fields ontolerant_reader. Protocol 1.1 adds no request body fields.
Clients MUST ignore unknown response fields at any depth. They MUST NOT reject a whole capabilities document over one entry.
The JSON Schemas describe servers, not clients. The response schemas
(middleware-capabilities, transform-capability, middleware-plan,
middleware-page, middleware-error, and the other response schemas) are
closed (additionalProperties: false): they describe exactly what a 1.1 server
emits, and the conformance kit holds servers to them. Clients MUST NOT
validate responses against them. A future minor version may add response
fields, and a client that rejected them would break forward compatibility.
Clients parse responses by the rules in this document; the fixture's
client-accept vectors (capabilities_parsing) deliberately include documents
the closed schemas reject. Parsing capabilities:
- Reject the document with
unsupported_versionif any of these fails:- it is a JSON object;
schema_version == 1;protocol, when present and well-formed, satisfiesmin ≤ 1 ≤ max;policy_revisionis a token;runtime_buildis a string;transformsis an array;limitshasdeadline_ms,request_bytes,segment_bytesandpage_bytesas positive safe integers;persistentandrecoveryare booleans;retention_secondsis a safe integer ≥ 0.
- Treat a malformed
protocol,featuresormax_retention_secondsas absent. A non-arrayfeaturestherefore means a legacy runtime. features= server features ∩ features this client knows, sorted. Ignore unknown and malformed entries.- Usable transforms: keep only well-formed entries (token
transform_idandimplementation_version, arrayeligible_segment_kinds) withdeterministic === trueandrecoveryin the client allowlist (exact_ccr). Keep the first entry for each id. An empty result is valid, andoptimizethen bypasses locally withunknown_capability. limits: a known 1.1 key whose value is not a positive safe integer is treated as absent. Unknown keys are ignored.- An unrecognized
modeis treated asrecord.trust_modeis informational.
Vectors: capabilities_parsing.
5. Policy revision (K3)
policy_revision is an opaque token identifying the transform set of one
capabilities view.
- Acceptance with
revision_tolerantactive: accept iff every id inpolicy.transformsis unique and currently advertised to this request's view.policy.revisionis ignored. - Acceptance otherwise (1.0 rule): accept iff
policy.revisionequals the current revision of the request's own view and every transform is advertised to that view. For a request withoutCaveman-Middleware-Featuresthat is the legacy view; for a 1.1 client that did not sendrevision_tolerantit is the full view. - A rejection is 400
unknown_capability. - The plan's
policy_revisionMUST be the server's current revision for the request's view.
Client
- MUST NOT reject a replacement because
transform_versiondiffers from the cachedimplementation_version. Persisted choices keep the version that produced them. The client still requirestransform_versionto be a token. - When
plan.policy_revisiondiffers from the cached revision, the client validates the plan as usual (§7), applies it if valid, and starts a single-flight capabilities refresh:- at most one refresh is in flight per runtime instance;
- the refresh is inline: the next optimize call fetches it within its own
deadline, after the breaker and
Retry-Afterchecks (§10), so it is an optimize-path request like any other; - concurrent calls keep the cached document until the refresh resolves;
- a failed refresh clears the cache.
- Cached capabilities expire after 300 seconds, measured on a monotonic clock; an expired view is refreshed the same way. A client whose cached view has no transform it can use also refreshes (single-flight) on the next call, instead of waiting for a restart or an error. It does so once: an unusable answer to that refresh is kept until it expires.
- Without a cached view, concurrent calls share one capabilities fetch; each
still consults the breaker first. Only the call that started the fetch (the
leader) records its outcome in the breaker; calls that joined it record
nothing for the fetch. One failed shared fetch is one breaker failure, not
one per waiter (vectors:
runtime_scenariosbootstrap_capabilities_single_flight,shared_capabilities_failure_records_one_outcome).
6. Errors and HTTP semantics (K4)
Every non-2xx body is the error envelope (middleware-error):
{"schema_version": 1, "error": {"code": "<code>"}}, where the code matches
^[a-z][a-z0-9_]{0,63}$. Servers MUST send Retry-After (whole seconds, ≥ 1)
on every 429 and 503, in both modes.
| Code | 1.1 status | 1.0 status | Retryable | Meaning |
|---|---|---|---|---|
invalid_request | 400 | 400 | no | Malformed or missing required input |
unsupported_version | 400 | 400 | no | schema_version outside protocol |
unknown_capability | 400 | 400 | no | Transform not supported / revision rejected (§5) |
invalid_range | 400 | 400 | no | Retrieve offset/limit out of range |
unauthorized | 401 | 401 | no | Missing or unknown credential |
forbidden_origin | 403 | 403 | no | Browser request |
forbidden_namespace | 403 | (new) | no | Principal may not use namespace |
not_found | 404 | 404 | no | Unknown or foreign handle; unknown route. Storage failures are never 404 |
request_timeout | 408 | (new) | yes | Request body not received before the deadline |
epoch_changed | 409 | 409 | no | Manifest is not an extension of the stored one, or sequence went backwards. Protocol 1.0 clients only: a 1.1 client starts a new epoch instead (§12) |
identity_conflict | 409 | 409 | yes | Concurrent writer conflict |
deleted | 410 | 410 | no | Scope revoked |
expired | 410 | 410 | no | Scope passed retention or max retention |
payload_limit | 413 | 413 | no | Body exceeds a size limit (actual size only) |
capacity | 429 | 503 | yes | Queue saturated |
quota_exceeded | 429 | 503 capacity | yes | Per-principal quota. 1.0 had no quota code: a 1.0 client gets 503 capacity (legacy_conditions) |
runtime_unavailable | 503 | 503 | yes | Storage or dependency outage |
recovery_unavailable | 503 | 503 | yes | Retrieve cannot read a stored original |
deadline | 504 | 504 | yes | Server work exceeded its deadline |
Per-condition 1.0 mappings (legacy_conditions in the fixture). When
http_status_v2 is inactive the server MUST answer these exactly as 1.0 did:
| Condition | 1.1 | 1.0 |
|---|---|---|
| Slow request body | 408 request_timeout | 413 payload_limit |
Retrieve limit < 4 or > page_bytes | 400 invalid_range | 413 payload_limit |
| No queue slot before the deadline | 429 capacity | 504 deadline |
Principal over quota_requests_per_minute | 429 quota_exceeded | 503 capacity |
Malformed recovery_binding | 400 invalid_request | 503 recovery_unavailable |
| Optimize: total saving ≤ overhead | 200 plan, reason not_smaller | 503 not_smaller |
| Optimize: stored plan/choice unreadable | 200 plan, reason cache_state_unavailable | 503 same code |
| Optimize: a concurrently published choice's original unreadable (a lost original is otherwise re-sealed from the request, or skips only its segment) | 200 plan, reason recovery_unavailable | 503 same code |
| Optimize: store capacity reached | 200 plan, reason capacity | 503 capacity |
Exception: an unreadable original found before the write. This is the
one optimize outcome that does not answer as 1.0 did, in either mode. When a
choice the server read before its write transaction names an original it can
no longer read, 1.0 answered 503 recovery_unavailable for the whole
request. Now:
- An original this store held (lost, or sealed with a key the runtime can no longer open) is stored again from the request's own copy, verified against the choice's digest, and the plan proceeds.
- A protocol 1.0 original that CCR no longer holds leaves only its segment
unreplaced, with skip reason
recovery_unavailable. The server answers the rest of the request as usual: a 200 plan,bypassedwhen that segment was the only candidate.
A 1.0 client already accepts both answers: a 200 plan, and
recovery_unavailable as a segment's skip reason (1.0 sent it for a segment
without a recovery binding). The recovery_unavailable row above, a choice
another writer published concurrently, keeps the 1.0 answer.
Decisions are 200. Under http_status_v2, an optimize outcome that is a
decision about the content is a 200 plan with status: "bypassed",
replacements: [], and every sent segment listed in skipped. Segments that
would have been replaced carry the plan's reason, and all other segments keep
their own reason. The plan satisfies middleware-plan and every client
invariant.
For all clients: a storage or database failure MUST be 503
runtime_unavailable, never 404 (1.0 retrieve answered 404).
Client classification (vectors: failure_classification). Outcomes are
derived from error.code, not the status, so a mixed fleet of 1.0 and 1.1
replicas behaves the same. Each outcome is
{reason, breaker, clear_capabilities, retry_after_ms}:
- Transport failure (connect, DNS, TLS, reset, truncated body):
runtime_unavailable, breaker yes, clear yes. - Client deadline elapsed:
deadline, breaker yes, clear no. - 3xx:
redirect_refused, breaker yes, clear yes. - Non-2xx whose body is not JSON, has no
error.code, or whose code violates the reason grammar:runtime_unavailable, breaker yes, clear yes. request_timeout: reasondeadline, breaker yes, clear no.- Otherwise reason = code. Breaker is the code's
breakerflag in §8, orstatus ≥ 500for a code not in §8. Clear is yes forruntime_unavailable,unknown_capabilityandunsupported_version, for a code not in §8 with status ≥ 500, and no otherwise. retry_after_ms, only for 429/503:min(delta_seconds × 1000, 30000)whenRetry-Afteris a non-negative integer, elsenull. HTTP-date values are ignored.
Decoding. A non-2xx body is decoded with invalid UTF-8 replaced, then
classified by the steps above, so a 429 carrying a stray byte is still
capacity with its Retry-After. A 2xx body must be strict UTF-8. Both SDKs
read JSON the same way: NaN, Infinity and a leading byte order mark are not
JSON (step 4, or runtime_unavailable for a 2xx body), and an integer-valued
number such as 500.0 is the integer 500.
A 2xx capabilities body that fails §4 step 1 is unsupported_version,
breaker yes, clear yes: an unusable document from the runtime is an invalid
response, like invalid_plan. A 2xx plan that fails §7 is invalid_plan,
breaker yes, clear yes.
7. Client-enforced plan constraints (K5)
A client MUST validate the whole plan before applying any replacement. Any
violation rejects the whole plan: bypass invalid_plan, counted by the breaker.
The 1.0 checks remain (middleware.fixtures.json invalid_plans). In addition,
for each replacement:
transform_idis inrequest.policy.transforms, and in the capabilities snapshot used to build the request itsrecoveryis in the client allowlist (1.1:exact_ccronly). Transforms withrecovery: "none"are never applied.recovery_handlematches^cmw_[a-f0-9]{48}$, andtextstarts with[caveman: shortened; exact original via caveman_retrieve handle=<recovery_handle>]\n.- The UTF-8 byte length of
textis strictly less than the UTF-8 byte length of the original segment content. UTF-16 or code-point length does not count. sha256 == SHA-256(UTF-8(text)), andoriginal_sha256equals the sent segment'ssha256.
Vectors: plan_constraints (patches apply to the base fixture documents).
8. Reason codes (K7)
Every reason a client reports (decision events, CallReport.reason, bypass
Optimization.reason, warn-once logs) is in this catalog. breaker is whether
the outcome counts as a breaker failure. warn_once is whether the first
occurrence per (adapter, reason) logs a warning. strict is the behavior in
strict mode:
raise:optimize()and the adapter call raiseMiddlewareError(code).ready: the reason is only surfaced byready(), which raises, and bypreflight(), which reports unavailable. The request path passes through.none: never an error.
Nothing raises at wrap time, and configuration errors never raise at construction.
| Code | Meaning | Breaker | Warn | Strict |
|---|---|---|---|---|
unsupported_version | Framework version outside the tested range, or runtime protocol excludes client | no | yes | ready (raise for protocol on request path) |
version_unverified | Framework version unreadable (bundled); features detected, call proceeds | no | yes | none |
version_unavailable | Version unreadable and required hooks missing; pass-through | no | yes | ready |
invalid_scope | Supplied scope not normalizable (§9) | no | yes | raise |
payload_budget | Candidate over budget (per candidate), or no candidate fits (whole call) | no | yes | none |
opaque_part | Image/bytes part: never sent, hashed into manifest; never a whole-call reason | no | no | none |
recovery_unbound | Compress mode but no owned recovery binding; local bypass, no I/O | no | yes | none |
recovery_name_conflict | Host already has a foreign caveman_retrieve tool | no | yes | raise |
adapter_error | Exception caught by the fail-open guard, including a manifest or model identity that is not serializable as well-formed UTF-8 JSON | no | yes | raise |
provider_state_retained | Provider would persist the compressed turn (e.g. Responses store) | no | yes | none |
capacity | Local concurrency full, or server 429/legacy 503 capacity | no | yes | raise |
circuit_open | Breaker open; local bypass | no | yes | raise |
deadline | Client deadline, 504, or 408 | yes | yes | raise |
runtime_unavailable | I/O failure, 5xx, unparseable error | yes | yes | raise |
invalid_plan | Plan failed §7 | yes | yes | raise |
unknown_capability | No usable transform, or server 400 | no | yes | raise |
no_candidate | Nothing eligible to send | no | no | none |
closed | Runtime closed; in-flight calls pass through | no | yes | none |
unsupported_shape | Candidate id invalid/duplicate or content ill-formed Unicode (per candidate) | no | yes | raise |
unsupported_provider | The provider or model family is not supported by this adapter; pass-through | no | yes | ready |
unsupported_request | The request shape or method is not eligible for this adapter; pass-through | no | yes | ready |
redirect_refused | Runtime answered 3xx | yes | yes | raise |
invalid_endpoint, remote_content_not_enabled, insecure_transport_not_enabled | Endpoint refused by §15; every call bypasses, no I/O | no | yes | ready |
invalid_configuration | Invalid option value (mode, deadlines, max_concurrency outside 1–1024); warns once, every call passes through with no I/O (an invalid mode reads as off) | no | yes | ready |
unauthorized, forbidden_origin, forbidden_namespace, invalid_request, payload_limit, not_found, deleted, quota_exceeded, recovery_unavailable | Server code passed through | no | yes | raise |
epoch_changed, identity_conflict, cache_state_unavailable | Server code passed through | no | yes | none |
expired, not_smaller | Server code passed through (natural lifecycle / result) | no | no | none |
protected, record, eligible, disabled | Plan or report outcomes, not failures | no | no | none |
The exact table is reason_catalog in the fixture and REASON_CATALOG in
each SDK.
unsupported_provider and unsupported_request describe endpoints that were
never eligible, such as an embeddings call or a provider the adapter does not
wrap. An adapter MAY warn once with them, but it MUST NOT emit a report or
decision event for such a call: reports describe calls that went through the
middleware path.
Warn-once:
- A call warns only when content passed through unchanged, for a reason whose
warn_onceis yes:- every bypass
optimize()ordecline()returns, naming the adapter or-when there is none; - each per-candidate
payload_budgetandunsupported_shapeskip on a call that still sends; report(), only when its status isskipped.
- every bypass
- TS logs with
console.warn; Python logsWARNINGonlogging.getLogger("caveman.middleware"). - The line MUST contain
adapter=<id or ->andreason=<code>. It MUST NOT contain content, scope values, handles or credentials. - Deduplication is per process, bounded at 1024 (adapter, reason) pairs.
Vectors: runtime_scenarios (warn_once_rules).
Local order. An optimize() call reports the first reason that applies:
- off
closedinvalid_scopecapacity(local concurrency)- only if capabilities are not cached:
circuit_open, then aRetry-Aftersuppression, then the capabilities fetch unknown_capability(no usable transform, compress mode)recovery_unbound(client and runtime both in compress mode, no owned binding)- candidate filtering and budget:
payload_budget,unsupported_shape,no_candidate circuit_open, then aRetry-Aftersuppression- send
9. Scope normalization (K6)
normalizeScopeToken(value) (TS) / normalize_scope_token(value) (Python):
- If
valueis not a string, or is"", return null. - If
valueis not well-formed Unicode (it has an unpaired surrogate), return null. - If
valuematches^[A-Za-z0-9._:/-]{1,256}$in full (no trailing-newline match), return it unchanged. - Otherwise return
"h-"+ the first 32 characters of the lowercase hex SHA-256 ofUTF-8(value). No Unicode normalization is applied.
normalizeScope(input) / normalize_scope(input):
namespaceandsession_idare required.branch_idandcache_epochdefault to"main"and"0"when absent,undefined, orNone.- A present member that normalizes to null, or a missing required member, makes the whole result null.
- Numbers are not stringified.
SDK entry points that take a scope (recovery, optimize, retrieve,
observe, deleteSession) MUST apply normalizeScope, so every route sees the
same normalized scope. In non-strict mode an invalid scope never raises:
optimizebypasses withinvalid_scope;recovery()returns null (TS) / None (Python);- adapters then make a recovery-free call, reporting
invalid_scopewhen a scope was supplied andrecovery_unboundwhen none was.
Vectors: scope_tokens, scopes.
10. Breaker, deadlines, back-pressure (K8)
Breaker (one per runtime instance; guards the optimize path, meaning the
capabilities fetch plus optimize). Parameters: 5 consecutive failures, a
window of 20 outcomes, 10 failures, 30 000 ms open.
- closed. Record each call outcome. A failure is any outcome with
breaker = yes. Every other outcome that involved I/O, 4xx included, counts as a success. After a failure, open the breaker when there are ≥ 5 consecutive failures, or when the last 20 outcomes are all recorded and ≥ 10 of them failed. - open. Calls bypass
circuit_openwith no I/O and record nothing. Oncenow − opened_at ≥ 30 000 ms, the state moves to half-open. - half-open. Exactly one call is sent as the probe, and concurrent calls get
circuit_open. A probe failure reopens the breaker, withopened_at = now. Any other outcome closes it and resets the counters and the window. - A call consults the breaker immediately before its first network request and records exactly one outcome after its last. A call that bypasses locally before any I/O neither consults nor records. A call that joined another call's shared capabilities fetch (§5) records nothing for that fetch; if the fetch failed, the call ends there and records nothing at all.
- An admitted call that ends with no outcome of its own (it only waited on another call's fetch, or its caller aborted it) records nothing, but in half-open it frees the probe slot so the next call can probe.
- Retrieve, receipts and delete neither consult nor feed the breaker.
nowis a monotonic clock, for the breaker,Retry-Afterwindows and the capabilities TTL alike: a wall-clock step (NTP, VM resume) never holds the breaker open or ends a window early.
Vectors: breaker_sequences (local = bypass before I/O).
Deadlines (vectors: deadlines):
- optimize:
deadlineMs/deadline_mswhen configured, else the cachedlimits.deadline_mscapped at 5000, else 500 (bootstrap:ready(),preflight()and a first call that must fetch capabilities). - retrieve and
deleteSession:retrieveDeadlineMs/retrieve_deadline_mswhen configured, elselimits.retrieve_deadline_mscapped at 30 000, else 5000. - The caps apply only to server-advertised values; a configured deadline is the
caller's choice. Every resolved deadline, configured ones included, is capped
at 2 147 483 647 ms (2^31 − 1), the longest timer both SDKs can arm
(
constants.defaults:deadline_cap_ms,retrieve_deadline_cap_ms,timer_cap_ms). - receipts use the optimize deadline.
- DNS, connect, the proxy CONNECT tunnel, TLS, status line, headers, upload and download all count inside the deadline: it bounds the whole exchange, not each read, so a peer trickling bytes cannot extend it.
Server queues.
- optimize and receipts share a queue of
limits.queue_depthslots (default 16) and thedeadline_msbudget. - retrieve and sessions/delete share their own queue,
limits.retrieve_queue_depth(default 16), and their ownretrieve_deadline_ms(default 5000), the deadline clients use for both. Retrieve MUST NOT hold the metadata writer while reading an original. capabilitiesis served from memory and is not queued.
Retry-After. After an outcome with retry_after_ms > 0, the client MUST
NOT send optimize-path requests until that time has elapsed. Calls in the
window bypass locally with the same reason, do no I/O, and record nothing.
Concurrency. Local in-flight optimize calls are bounded by
maxConcurrency / max_concurrency (integer 1–1024, default 16). The excess
bypasses with capacity. Retrieve and delete have their own budget of the same
size, so slow recoveries never take the optimize path's slots.
11. Budgets (K10)
No whole-call bypass happens while at least one candidate fits.
-
Local filters (these candidates are never sent):
protected→counts.protected;opaque→counts.opaque;- invalid or duplicate id, or ill-formed content →
unsupported_shape,counts.unsupported; - UTF-8 content larger than
limits.segment_bytes→payload_budget,counts.budget_skipped.
-
Sizes.
envelope= UTF-8 bytes of the exact request serialization withsegments: [](compact JSON, non-ASCII as UTF-8, schema key order).item.bytes= UTF-8 bytes of the serialized segment + 1.max_bytes = limits.request_bytes − envelope.max_segments = limits.max_segments(default 256). -
Admission (
planBudget/plan_budget; vectors:budgets):- tier A holds items whose
key(<original_sha256>:<segment_id>) was replaced before in this scope, in input order; - tier B holds the rest, newest first (reverse input order);
- walk A then B and admit an item iff
admitted < max_segmentsandused + bytes ≤ max_bytes; otherwise skip it (payload_budget) and continue; - admitted items go on the wire in input order.
- tier A holds items whose
-
Replaced memory. A per-runtime LRU of at most 4096
(scope, key)entries. Every replacement in an accepted plan is added. -
Whole-call reason when nothing is sent:
payload_budgetif any candidate was skipped for budget; elseunsupported_shapeif any was unsupported; elseno_candidate. -
Manifest. The SDK sends the head of the manifest, at most
limits.max_manifest_itemsitems (default 4096). Adapters bound hashing withmanifestBytes/manifest_bytes(default 2 MiB) usingmanifestWindow(sizes, maxItems, maxBytes), the largestkwithk ≤ maxItemsandΣ sizes[0..k) ≤ maxBytes. A head prefix stays prefix-stable as history grows, soepoch_changedis not triggered. An oversize manifest MUST NOT bypass the call.sequencedefaults to the untruncated history length. Vectors:manifest_windows. -
Opaque parts (images, bytes) are serialized in the manifest as
{"caveman_opaque": h}:- bytes:
his the SHA-256 hex of the bytes; - strings (data URLs, base64):
his the SHA-256 hex of the UTF-8 string; - anything else, or an ill-formed string:
his"unhashable".
They never cause a bypass. Vectors:
opaque_manifest_values. - bytes:
All budgets are reported: server budgets in capabilities limits (§13) and
client skips in decision-event counts (§14).
12. Lifecycle (K11) and receipts (K13)
Advertised by originals_lifecycle. These are server obligations for every
client.
- Retention. A scope has
created_atandexpires_at.- Creation sets
expires_at = now + retention_seconds. - A successful optimize or retrieve sets
expires_at = min(now + retention_seconds, created_at + max_retention_seconds). max_retention_secondsdefaults to 604 800 and MUST be ≥retention_seconds.
- Creation sets
- Epochs. An optimize whose
context_manifestdoes not extend the scope's stored manifest, or whosesequenceis below the stored one (a trimmed or summarized history, a nested agent sharing the scope), starts a new epoch when the request carriesCaveman-Middleware-Features: the scope takes the new manifest and sequence, keeps its choices and grants, and the optimize proceeds as usual. Earlier recovery handles keep working and a repeated segment keeps its replacement bytes. A protocol 1.0 client gets 409epoch_changedinstead, as in 1.0. The plan body is unchanged (its schema is closed); the reference runtime counts new epochs incaveman_middleware_epoch_rebaselines_total. - Expiry. An expired scope answers 410
expired. Its originals, choices, grants and plans become unreadable at once and MUST be physically deleted by the next sweeps. Sweeps MUST make progress across all expired scopes, not revisit the same batch. Only a content-free tombstone may remain, for the grace period, to keep answering 410. - Originals are stored per authority. They are encrypted at rest when a key
is configured, and they never outlive their scope. An original written by an
optimize that did not publish a plan (aborted,
not_smaller, error) MUST NOT persist. When a reused choice's stored original is missing or cannot be opened (for example sealed under a rotated-away key), an optimize that sends the same content, verified against the choice'ssha256, stores it again; the segment is replaced as usual rather than bypassed. - Plans are kept for idempotent replay (same
idempotency_keyand request body) formin(900 s, retention_seconds): long enough for an SDK retry within its deadline and a framework retry after a provider timeout. A later request with the same key is planned afresh and, because choices persist, gets the same replacement bytes. The same key with a different body while the plan is kept is 409identity_conflict. sessions/delete- Synchronously deletes every scope, choice, grant, plan, receipt and original of the request's authority. It runs on the retrieve queue and deadline (§10). The revocation commits first, so every later use answers 410 at once however large the session; the content then goes in bounded batches, each its own transaction.
- Answers 200
{"schema_version":1,"status":"revoked","originals_deleted":true,"deleted":{"scopes","choices","grants","originals"}}. This shape goes to every client; 1.0 clients ignore the body. originals_deletedisfalsewhen the authority holds grants a 1.0 runtime issued: their originals live in the process-wide CCR, whichsessions/deletecannot remove, so they stay until that store expires them. It staysfalsefor such an authority on a retried delete and after a sweep.- Is idempotent: an unknown scope answers the same shape with zero counts. The counts cover what this call removed, so a retry of a partly finished delete reports only the remainder. The expiry sweep may purge part of a delete's content between its batches; the call does not count that part, so its counts can be lower than what the session held.
- If deletion fails, including a storage write conflict, the answer is 503
runtime_unavailable(or 504deadlinewhen it ran out of time), never a success and never 409. A retry resumes where it stopped; the expiry sweep finishes a delete nobody retries. - Later use of the scope answers 410
deletedfor the grace period. - Against a 1.0 runtime, clients surface
originals_deleted: falseas received.
- Receipts MUST NOT create per-call rows that outlive
retention_seconds. Servers SHOULD aggregate into per-(authority, UTC hour,event_kind) counters with token sums, deduplicating(logical_call_id, attempt_id, event_kind)within retention. The first write wins: a retry of the same triple, even with a different body, answers the same success and changes nothing. A receipt for a scope revoked bysessions/deleteis 410deletedand stores nothing. The body limit islimits.receipt_bytes(413 above it). The success response is always{"schema_version":1,"status":"recorded","basis":"client_observed","verified_saved_usd":0}.
13. Capabilities limits (K12)
Every value is a positive safe integer; SDK 1.1.0 rejects the document otherwise. A key that does not apply is omitted, never sent as 0 or null.
| Key | Default | Client use |
|---|---|---|
deadline_ms | 500 | optimize deadline |
request_bytes | 2 097 152 | budget |
segment_bytes | 524 288 | per-candidate cap |
page_bytes | 262 144 | retrieve page size |
retrieve_deadline_ms | 5000 | retrieve/delete deadline |
queue_depth | 16 | informational |
retrieve_queue_depth | 16 | informational |
max_segments | 256 | budget |
max_manifest_items | 4096 | manifest head |
receipt_bytes | 16 384 | receipt cap |
quota_requests_per_minute | omitted = unlimited | informational, per principal |
Top level: max_retention_seconds (§12). Clients fill an absent
retrieve_deadline_ms, max_segments, max_manifest_items or
receipt_bytes with the default above. The informational keys
(queue_depth, retrieve_queue_depth, quota_requests_per_minute) and
max_retention_seconds stay null when absent: their defaults describe the
reference server, and a client does not act on them (capabilities_parsing →
limits).
14. Observability (K9)
Decision event (middleware-decision-event). One event per report(),
delivered to onDecision / on_decision. Keys are identical in both SDKs:
schema_versionstatus:applied|reused|skipped|recorded|disabledreasonadapter,logical_call_id,attempt_id: token or nulltransform_idslatency_ms: optimize duration in integer milliseconds, truncated; 0 when there was no optimizecounts:{candidates, sent, protected, opaque, unsupported, budget_skipped, skipped, replaced, reused}, wherecandidates = sent + protected + opaque + unsupported + budget_skippedandskipped = sent − replaced: a sent segment is skipped whether the runtime skipped it or the call failed after admission.disabledevents count zero.runtime_build: the plan'sruntime_buildwhen there is a plan (the replica that produced it), else that of the capabilities the call used, else nullcache_continuity:persistent_choices|unavailable|off
Vectors: examples.decision_events, runtime_scenarios.
Events MUST NOT contain content, scope values, handles or credentials. A sink
exception MUST NOT affect the call. no_candidate emits an event too.
CallReport / onReport and onDiagnostic keep working unchanged.
Warn-once line (§8). Both SDKs MUST log exactly
Caveman middleware passed content through unchanged: adapter=<id or -> reason=<code>;
any extra field follows as key=value. The one exception is
version_unverified, whose call proceeds: its line is
Caveman middleware is running on an unverified framework version: adapter=<id or -> reason=version_unverified.
OpenTelemetry is opt-in. The SDK never imports OTel unless the caller supplies a tracer and/or meter.
- Spans (kind CLIENT):
caveman.middleware.optimize,caveman.middleware.retrieve,caveman.middleware.receipt. - Attributes:
caveman.middleware.{adapter,status,reason,runtime_build,policy_revision,candidates,sent,replaced,reused,skipped,budget_skipped}, plusserver.address,server.port,http.response.status_code, anderror.type(= reason) on failures. - Span status is ERROR only for breaker-counted reasons.
- Receipt spans carry usage as
gen_ai.usage.input_tokens,gen_ai.usage.output_tokens,gen_ai.usage.cache_read.input_tokensandgen_ai.usage.cache_creation.input_tokens, taken frominput_tokens,output_tokens,cache_read_tokensandcache_write_tokens.reasoning_tokensis not exported. - Metrics:
- counter
caveman.middleware.decisions(unit{decision}; attributescaveman.middleware.adapter,.status,.reason); - histogram
caveman.middleware.duration(units; attributescaveman.middleware.operation=optimize|retrieve|receipt, pluserror.typeon failure).
- counter
Headers (client → server):
Caveman-Middleware-Features(§3).Caveman-Middleware-Client: space-separatedproduct/versiontokens, at most 256 chars, e.g.caveman-sdk-typescript/1.2.0orcaveman-sdk-python/1.2.0; adapters MAY append their own. Servers log it and MUST NOT branch on it.traceparentandtracestate(W3C): sent only when the caller supplied a tracer. They carry the SDK's own CLIENT span for the request (a child of the active context), so runtime spans nest under it. Without a tracer the SDK sends no trace headers, even if the application has an active context.
Server metrics (SHOULD, Prometheus text format with HELP/TYPE):
caveman_middleware_requests_total{route,status,code}caveman_middleware_request_duration_seconds{route}caveman_middleware_queue_depth{queue}caveman_middleware_unauthorized_total
15. Transport (K14)
resolveEndpoint / resolve_endpoint, vectors endpoints:
- The endpoint is validated as written, before any URL parser can trim,
unescape or reinterpret it. Anything outside this grammar is
invalid_endpoint:scheme://host[:port][/prefix][/]. - Scheme is
httporhttps. No userinfo, query, fragment, whitespace or control characters anywhere. - Host is a bracketed IPv6 literal, a dotted quad of four decimal octets
without leading zeros, or a name from
[A-Za-z0-9._-]whose last label is not numeric. Shorthand IPv4 (127.1,0x7f.1,2130706433) and percent-encoding (ex%61mple.com) are refused, never reinterpreted. - Port, when present, is 1–65535 without leading zeros; an empty port is refused.
- Path segments are non-empty, not
.or.., from[A-Za-z0-9._~-](no percent-encoding). - A trailing
/is optional. The prefix is preserved and routes are appended after it. Scheme and host are lowercased; nothing else is rewritten (an explicit default port stays). - Loopback means
localhost,127.0.0.1and::1(any spelling of that IPv6 address). - A non-loopback endpoint requires
allowRemoteContent/allow_remote_content(elseremote_content_not_enabled). - Non-loopback
httpadditionally requiresallowInsecureTransport/allow_insecure_transport(elseinsecure_transport_not_enabled). - Other violations give
invalid_endpoint. - Per decision 3 these surface through
ready()/preflight(), not by throwing at construction.
resolveProxy / resolve_proxy, vectors proxies:
- Loopback targets are never proxied.
httpstargets usehttps_proxy, elseHTTPS_PROXY.httptargets usehttp_proxy, elseHTTP_PROXY. Empty values count as unset.- If
no_proxy(elseNO_PROXY) matches the target, no proxy is used. The value is a comma list, trimmed and case-insensitive:*matches everything;hostor.hostmatches that host and its subdomains (label boundary only);host:portmatches only that port;- IP literals match exactly;
- CIDR is not supported.
Default transports. Both SDKs apply resolveProxy themselves: https
goes through a CONNECT tunnel, http in absolute form, and loopback is never
proxied, so neither the request content nor the runtime credential reaches a
proxy for a local runtime. The TS SDK uses global fetch unless, on Node, a
proxy applies, ca is set or Node runs with NODE_USE_ENV_PROXY (whose
fetch would proxy loopback too); then it uses its own node:http transport.
A caller-supplied transport (TS fetch, Python transport) owns proxying;
behind a proxy it should run with NO_PROXY=localhost,127.0.0.1,::1.
Default transports MUST NOT reuse a pooled connection that the peer (runtime or load balancer) closed while idle, and SHOULD retry a request that still meets one once on a fresh connection instead of reporting a runtime failure that would feed the breaker.
Other rules
- Python accepts
ssl_contextand an injectable transport. TS keepsfetchinjection and acceptsca(PEM certificates that replace the default trust store for the runtime connection; default Node transport only). - Clients SHOULD reuse connections.
- The credential MUST NOT appear in
JSON.stringify,inspect,reprorvars()output.
16. Compatibility and deprecation
- Additive only within 1.x.
schema_versionstays 1. A change that could break a conforming client or server of the previous minor version MUST be negotiated (§3) or wait for a new major version. Such changes include:- removing or renaming a field;
- narrowing a type or enum;
- adding a required request field;
- sending 1.0 clients a transform,
limitsvalue or status they reject; - changing an existing code's status without negotiation.
- Adding a response field is additive for clients, which ignore unknown response fields (§4) and MUST NOT validate responses against the closed JSON Schemas. The schemas then gain the field in the same release, because they describe exactly what a server of that version emits.
- N-1 both directions. A runtime MUST serve clients of the previous minor
protocol with that protocol's shapes and statuses: today, protocol 1.0
clients (SDK 1.1.0, middleware
0.1.0-alpha.2/0.1.0a1) via the legacy mappings above. A client MUST work against a runtime of the previous minor protocol: today,bin-v1.1.7. - Support window. Protocol N-1 stays supported for at least 12 months after the first release of protocol N. For 1.0 that runs to at least 2027-09-30.
- Deprecation. A deprecation is announced in this document and in the
contracts
CHANGELOG.mdat least 12 months before removal. Removal requires a new major (schema_version2,protocol.min2). - Exceptions. Bug fixes that replace an incorrect status with
runtime_unavailable(§6), server-internal identity changes (§2), and lifecycle guarantees (§12) apply to every client without negotiation.
Appendix: SDK helpers that the vectors exercise
| Fixture section | TypeScript | Python |
|---|---|---|
scope_tokens, scopes | normalizeScopeToken, normalizeScope | normalize_scope_token, normalize_scope |
capabilities_parsing | parseCapabilities(value): CapabilitiesView | parse_capabilities(value) -> CapabilitiesView |
plan_constraints | validatePlan(plan, request, inputDigest, caps) | validate.plan(plan, request, input_digest, caps) |
failure_classification | classifyFailure(input): FailureOutcome | classify_failure(input) -> FailureOutcome |
breaker_sequences | new CircuitBreaker(), .allow(nowMs), .record(result, nowMs), .state | CircuitBreaker(), .allow(now_ms), .record(result, now_ms), .state |
deadlines | resolveDeadlines({deadlineMs, retrieveDeadlineMs}, limits | null) | resolve_deadlines(deadline_ms, retrieve_deadline_ms, limits) |
budgets | planBudget(items, {maxSegments, maxBytes}, replaced) | plan_budget(items, max_segments=, max_bytes=, replaced=) |
manifest_windows | manifestWindow(sizes, maxItems, maxBytes) | manifest_window(sizes, max_items, max_bytes) |
opaque_manifest_values | opaqueManifestValue(value) | opaque_manifest_value(value) |
endpoints | resolveEndpoint(endpoint, {allowRemoteContent, allowInsecureTransport}) | resolve_endpoint(endpoint, allow_remote_content=, allow_insecure_transport=) |
proxies | resolveProxy(url, env) | resolve_proxy(url, env) |
constants, reason_catalog | constants in middleware/types.ts | constants in middleware/types.py |
runtime_scenarios | createMiddlewareRuntime({fetch}) over a scripted fetch | MiddlewareRuntime(transport=...) over a scripted transport |