JuliusBrussee/caveman
tldr.page
Middleware Protocol

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:

ArtifactPath
JSON Schemaspackages/shared/contracts/schemas/middleware-*.schema.json
OpenAPI 3.1packages/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).

RouteMethodRequest schema200 body schema
capabilitiesGETnonemiddleware-capabilities
optimizePOSTmiddleware-optimizemiddleware-plan
retrievePOSTmiddleware-retrievemiddleware-page
receiptsPOSTmiddleware-receiptmiddleware-receipt-response
sessions/deletePOSTmiddleware-session-deletemiddleware-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/json in UTF-8. The server answers any other content type with 400 invalid_request.
  • Every response body is JSON. Servers MUST send Cache-Control: no-store and X-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 (a text/plain POST 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 503 runtime_unavailable and Retry-After: 1 before browser refusal and authentication run.
  • Every POST body carries schema_version. A value outside protocol is 400 unsupported_version on every route, sessions/delete included. The required-field check runs first: a body missing a required field is 400 invalid_request whatever its schema_version, so schema_version: 2 with an incomplete body gets invalid_request, not unsupported_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 SEQUENCE next to, or instead of, sequence) is 400 invalid_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 (outcome payload_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 404 not_found, so the response never reveals whether a foreign handle exists.
  • scope_id = H(authority, adapter.id, adapter.serialization_revision). scope_id MUST 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 derived scope_id from 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 its transform_id is in request.policy.transforms. Otherwise the segment is skipped with unknown_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.

FeatureDirectionMeaning when active
tolerant_readerserver advertisesServer ignores unknown request fields (§4). Future optional request fields are sent only to servers advertising it.
revision_tolerantbothRevision acceptance per §5.
http_status_v2bothStatus mapping per §6 (decisions are 200; 408/429/503 + Retry-After).
originals_lifecycleserver advertisesDelete 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 recovery is not exact_ccr or none, or whose deterministic is not true;
  • has only positive safe integers in limits;
  • carries a policy_revision computed 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.0 reject unknown fields (DisallowUnknownFields), which is why clients gate new body fields on tolerant_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:

  1. Reject the document with unsupported_version if any of these fails:
    • it is a JSON object;
    • schema_version == 1;
    • protocol, when present and well-formed, satisfies min ≤ 1 ≤ max;
    • policy_revision is a token;
    • runtime_build is a string;
    • transforms is an array;
    • limits has deadline_ms, request_bytes, segment_bytes and page_bytes as positive safe integers;
    • persistent and recovery are booleans;
    • retention_seconds is a safe integer ≥ 0.
  2. Treat a malformed protocol, features or max_retention_seconds as absent. A non-array features therefore means a legacy runtime.
  3. features = server features ∩ features this client knows, sorted. Ignore unknown and malformed entries.
  4. Usable transforms: keep only well-formed entries (token transform_id and implementation_version, array eligible_segment_kinds) with deterministic === true and recovery in the client allowlist (exact_ccr). Keep the first entry for each id. An empty result is valid, and optimize then bypasses locally with unknown_capability.
  5. limits: a known 1.1 key whose value is not a positive safe integer is treated as absent. Unknown keys are ignored.
  6. An unrecognized mode is treated as record. trust_mode is 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_tolerant active: accept iff every id in policy.transforms is unique and currently advertised to this request's view. policy.revision is ignored.
  • Acceptance otherwise (1.0 rule): accept iff policy.revision equals the current revision of the request's own view and every transform is advertised to that view. For a request without Caveman-Middleware-Features that is the legacy view; for a 1.1 client that did not send revision_tolerant it is the full view.
  • A rejection is 400 unknown_capability.
  • The plan's policy_revision MUST be the server's current revision for the request's view.

Client

  • MUST NOT reject a replacement because transform_version differs from the cached implementation_version. Persisted choices keep the version that produced them. The client still requires transform_version to be a token.
  • When plan.policy_revision differs 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-After checks (§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_scenarios bootstrap_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.

Code1.1 status1.0 statusRetryableMeaning
invalid_request400400noMalformed or missing required input
unsupported_version400400noschema_version outside protocol
unknown_capability400400noTransform not supported / revision rejected (§5)
invalid_range400400noRetrieve offset/limit out of range
unauthorized401401noMissing or unknown credential
forbidden_origin403403noBrowser request
forbidden_namespace403(new)noPrincipal may not use namespace
not_found404404noUnknown or foreign handle; unknown route. Storage failures are never 404
request_timeout408(new)yesRequest body not received before the deadline
epoch_changed409409noManifest 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_conflict409409yesConcurrent writer conflict
deleted410410noScope revoked
expired410410noScope passed retention or max retention
payload_limit413413noBody exceeds a size limit (actual size only)
capacity429503yesQueue saturated
quota_exceeded429503 capacityyesPer-principal quota. 1.0 had no quota code: a 1.0 client gets 503 capacity (legacy_conditions)
runtime_unavailable503503yesStorage or dependency outage
recovery_unavailable503503yesRetrieve cannot read a stored original
deadline504504yesServer 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:

Condition1.11.0
Slow request body408 request_timeout413 payload_limit
Retrieve limit < 4 or > page_bytes400 invalid_range413 payload_limit
No queue slot before the deadline429 capacity504 deadline
Principal over quota_requests_per_minute429 quota_exceeded503 capacity
Malformed recovery_binding400 invalid_request503 recovery_unavailable
Optimize: total saving ≤ overhead200 plan, reason not_smaller503 not_smaller
Optimize: stored plan/choice unreadable200 plan, reason cache_state_unavailable503 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_unavailable503 same code
Optimize: store capacity reached200 plan, reason capacity503 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, bypassed when 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}:

  1. Transport failure (connect, DNS, TLS, reset, truncated body): runtime_unavailable, breaker yes, clear yes.
  2. Client deadline elapsed: deadline, breaker yes, clear no.
  3. 3xx: redirect_refused, breaker yes, clear yes.
  4. Non-2xx whose body is not JSON, has no error.code, or whose code violates the reason grammar: runtime_unavailable, breaker yes, clear yes.
  5. request_timeout: reason deadline, breaker yes, clear no.
  6. Otherwise reason = code. Breaker is the code's breaker flag in §8, or status ≥ 500 for a code not in §8. Clear is yes for runtime_unavailable, unknown_capability and unsupported_version, for a code not in §8 with status ≥ 500, and no otherwise.
  7. retry_after_ms, only for 429/503: min(delta_seconds × 1000, 30000) when Retry-After is a non-negative integer, else null. 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:

  1. transform_id is in request.policy.transforms, and in the capabilities snapshot used to build the request its recovery is in the client allowlist (1.1: exact_ccr only). Transforms with recovery: "none" are never applied.
  2. recovery_handle matches ^cmw_[a-f0-9]{48}$, and text starts with [caveman: shortened; exact original via caveman_retrieve handle=<recovery_handle>]\n.
  3. The UTF-8 byte length of text is strictly less than the UTF-8 byte length of the original segment content. UTF-16 or code-point length does not count.
  4. sha256 == SHA-256(UTF-8(text)), and original_sha256 equals the sent segment's sha256.

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 raise MiddlewareError(code).
  • ready: the reason is only surfaced by ready(), which raises, and by preflight(), which reports unavailable. The request path passes through.
  • none: never an error.

Nothing raises at wrap time, and configuration errors never raise at construction.

CodeMeaningBreakerWarnStrict
unsupported_versionFramework version outside the tested range, or runtime protocol excludes clientnoyesready (raise for protocol on request path)
version_unverifiedFramework version unreadable (bundled); features detected, call proceedsnoyesnone
version_unavailableVersion unreadable and required hooks missing; pass-throughnoyesready
invalid_scopeSupplied scope not normalizable (§9)noyesraise
payload_budgetCandidate over budget (per candidate), or no candidate fits (whole call)noyesnone
opaque_partImage/bytes part: never sent, hashed into manifest; never a whole-call reasonnononone
recovery_unboundCompress mode but no owned recovery binding; local bypass, no I/Onoyesnone
recovery_name_conflictHost already has a foreign caveman_retrieve toolnoyesraise
adapter_errorException caught by the fail-open guard, including a manifest or model identity that is not serializable as well-formed UTF-8 JSONnoyesraise
provider_state_retainedProvider would persist the compressed turn (e.g. Responses store)noyesnone
capacityLocal concurrency full, or server 429/legacy 503 capacitynoyesraise
circuit_openBreaker open; local bypassnoyesraise
deadlineClient deadline, 504, or 408yesyesraise
runtime_unavailableI/O failure, 5xx, unparseable erroryesyesraise
invalid_planPlan failed §7yesyesraise
unknown_capabilityNo usable transform, or server 400noyesraise
no_candidateNothing eligible to sendnononone
closedRuntime closed; in-flight calls pass throughnoyesnone
unsupported_shapeCandidate id invalid/duplicate or content ill-formed Unicode (per candidate)noyesraise
unsupported_providerThe provider or model family is not supported by this adapter; pass-throughnoyesready
unsupported_requestThe request shape or method is not eligible for this adapter; pass-throughnoyesready
redirect_refusedRuntime answered 3xxyesyesraise
invalid_endpoint, remote_content_not_enabled, insecure_transport_not_enabledEndpoint refused by §15; every call bypasses, no I/Onoyesready
invalid_configurationInvalid 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)noyesready
unauthorized, forbidden_origin, forbidden_namespace, invalid_request, payload_limit, not_found, deleted, quota_exceeded, recovery_unavailableServer code passed throughnoyesraise
epoch_changed, identity_conflict, cache_state_unavailableServer code passed throughnoyesnone
expired, not_smallerServer code passed through (natural lifecycle / result)nononone
protected, record, eligible, disabledPlan or report outcomes, not failuresnononone

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_once is yes:
    • every bypass optimize() or decline() returns, naming the adapter or - when there is none;
    • each per-candidate payload_budget and unsupported_shape skip on a call that still sends;
    • report(), only when its status is skipped.
  • TS logs with console.warn; Python logs WARNING on logging.getLogger("caveman.middleware").
  • The line MUST contain adapter=<id or -> and reason=<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:

  1. off
  2. closed
  3. invalid_scope
  4. capacity (local concurrency)
  5. only if capabilities are not cached: circuit_open, then a Retry-After suppression, then the capabilities fetch
  6. unknown_capability (no usable transform, compress mode)
  7. recovery_unbound (client and runtime both in compress mode, no owned binding)
  8. candidate filtering and budget: payload_budget, unsupported_shape, no_candidate
  9. circuit_open, then a Retry-After suppression
  10. send

9. Scope normalization (K6)

normalizeScopeToken(value) (TS) / normalize_scope_token(value) (Python):

  1. If value is not a string, or is "", return null.
  2. If value is not well-formed Unicode (it has an unpaired surrogate), return null.
  3. If value matches ^[A-Za-z0-9._:/-]{1,256}$ in full (no trailing-newline match), return it unchanged.
  4. Otherwise return "h-" + the first 32 characters of the lowercase hex SHA-256 of UTF-8(value). No Unicode normalization is applied.

normalizeScope(input) / normalize_scope(input):

  • namespace and session_id are required.
  • branch_id and cache_epoch default to "main" and "0" when absent, undefined, or None.
  • 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:

  • optimize bypasses with invalid_scope;
  • recovery() returns null (TS) / None (Python);
  • adapters then make a recovery-free call, reporting invalid_scope when a scope was supplied and recovery_unbound when 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_open with no I/O and record nothing. Once now − 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, with opened_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.
  • now is a monotonic clock, for the breaker, Retry-After windows 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_ms when configured, else the cached limits.deadline_ms capped at 5000, else 500 (bootstrap: ready(), preflight() and a first call that must fetch capabilities).
  • retrieve and deleteSession: retrieveDeadlineMs / retrieve_deadline_ms when configured, else limits.retrieve_deadline_ms capped 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_depth slots (default 16) and the deadline_ms budget.
  • retrieve and sessions/delete share their own queue, limits.retrieve_queue_depth (default 16), and their own retrieve_deadline_ms (default 5000), the deadline clients use for both. Retrieve MUST NOT hold the metadata writer while reading an original.
  • capabilities is 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.

  1. 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.
  2. Sizes. envelope = UTF-8 bytes of the exact request serialization with segments: [] (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).

  3. 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_segments and used + bytes ≤ max_bytes; otherwise skip it (payload_budget) and continue;
    • admitted items go on the wire in input order.
  4. Replaced memory. A per-runtime LRU of at most 4096 (scope, key) entries. Every replacement in an accepted plan is added.

  5. Whole-call reason when nothing is sent: payload_budget if any candidate was skipped for budget; else unsupported_shape if any was unsupported; else no_candidate.

  6. Manifest. The SDK sends the head of the manifest, at most limits.max_manifest_items items (default 4096). Adapters bound hashing with manifestBytes / manifest_bytes (default 2 MiB) using manifestWindow(sizes, maxItems, maxBytes), the largest k with k ≤ maxItems and Σ sizes[0..k) ≤ maxBytes. A head prefix stays prefix-stable as history grows, so epoch_changed is not triggered. An oversize manifest MUST NOT bypass the call. sequence defaults to the untruncated history length. Vectors: manifest_windows.

  7. Opaque parts (images, bytes) are serialized in the manifest as {"caveman_opaque": h}:

    • bytes: h is the SHA-256 hex of the bytes;
    • strings (data URLs, base64): h is the SHA-256 hex of the UTF-8 string;
    • anything else, or an ill-formed string: h is "unhashable".

    They never cause a bypass. Vectors: opaque_manifest_values.

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_at and expires_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_seconds defaults to 604 800 and MUST be ≥ retention_seconds.
  • Epochs. An optimize whose context_manifest does not extend the scope's stored manifest, or whose sequence is below the stored one (a trimmed or summarized history, a nested agent sharing the scope), starts a new epoch when the request carries Caveman-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 409 epoch_changed instead, as in 1.0. The plan body is unchanged (its schema is closed); the reference runtime counts new epochs in caveman_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's sha256, stores it again; the segment is replaced as usual rather than bypassed.
  • Plans are kept for idempotent replay (same idempotency_key and request body) for min(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 409 identity_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_deleted is false when the authority holds grants a 1.0 runtime issued: their originals live in the process-wide CCR, which sessions/delete cannot remove, so they stay until that store expires them. It stays false for 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 504 deadline when 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 deleted for the grace period.
    • Against a 1.0 runtime, clients surface originals_deleted: false as 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 by sessions/delete is 410 deleted and stores nothing. The body limit is limits.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.

KeyDefaultClient use
deadline_ms500optimize deadline
request_bytes2 097 152budget
segment_bytes524 288per-candidate cap
page_bytes262 144retrieve page size
retrieve_deadline_ms5000retrieve/delete deadline
queue_depth16informational
retrieve_queue_depth16informational
max_segments256budget
max_manifest_items4096manifest head
receipt_bytes16 384receipt cap
quota_requests_per_minuteomitted = unlimitedinformational, 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_version
  • status: applied|reused|skipped|recorded|disabled
  • reason
  • adapter, logical_call_id, attempt_id: token or null
  • transform_ids
  • latency_ms: optimize duration in integer milliseconds, truncated; 0 when there was no optimize
  • counts: {candidates, sent, protected, opaque, unsupported, budget_skipped, skipped, replaced, reused}, where candidates = sent + protected + opaque + unsupported + budget_skipped and skipped = sent − replaced: a sent segment is skipped whether the runtime skipped it or the call failed after admission. disabled events count zero.
  • runtime_build: the plan's runtime_build when there is a plan (the replica that produced it), else that of the capabilities the call used, else null
  • cache_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}, plus server.address, server.port, http.response.status_code, and error.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_tokens and gen_ai.usage.cache_creation.input_tokens, taken from input_tokens, output_tokens, cache_read_tokens and cache_write_tokens. reasoning_tokens is not exported.
  • Metrics:
    • counter caveman.middleware.decisions (unit {decision}; attributes caveman.middleware.adapter, .status, .reason);
    • histogram caveman.middleware.duration (unit s; attributes caveman.middleware.operation = optimize|retrieve|receipt, plus error.type on failure).

Headers (client → server):

  • Caveman-Middleware-Features (§3).
  • Caveman-Middleware-Client: space-separated product/version tokens, at most 256 chars, e.g. caveman-sdk-typescript/1.2.0 or caveman-sdk-python/1.2.0; adapters MAY append their own. Servers log it and MUST NOT branch on it.
  • traceparent and tracestate (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 http or https. 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.1 and ::1 (any spelling of that IPv6 address).
  • A non-loopback endpoint requires allowRemoteContent / allow_remote_content (else remote_content_not_enabled).
  • Non-loopback http additionally requires allowInsecureTransport / allow_insecure_transport (else insecure_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.
  • https targets use https_proxy, else HTTPS_PROXY. http targets use http_proxy, else HTTP_PROXY. Empty values count as unset.
  • If no_proxy (else NO_PROXY) matches the target, no proxy is used. The value is a comma list, trimmed and case-insensitive:
    • * matches everything;
    • host or .host matches that host and its subdomains (label boundary only);
    • host:port matches 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_context and an injectable transport. TS keeps fetch injection and accepts ca (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, repr or vars() output.

16. Compatibility and deprecation

  • Additive only within 1.x. schema_version stays 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, limits value 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.md at least 12 months before removal. Removal requires a new major (schema_version 2, protocol.min 2).
  • 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 sectionTypeScriptPython
scope_tokens, scopesnormalizeScopeToken, normalizeScopenormalize_scope_token, normalize_scope
capabilities_parsingparseCapabilities(value): CapabilitiesViewparse_capabilities(value) -> CapabilitiesView
plan_constraintsvalidatePlan(plan, request, inputDigest, caps)validate.plan(plan, request, input_digest, caps)
failure_classificationclassifyFailure(input): FailureOutcomeclassify_failure(input) -> FailureOutcome
breaker_sequencesnew CircuitBreaker(), .allow(nowMs), .record(result, nowMs), .stateCircuitBreaker(), .allow(now_ms), .record(result, now_ms), .state
deadlinesresolveDeadlines({deadlineMs, retrieveDeadlineMs}, limits | null)resolve_deadlines(deadline_ms, retrieve_deadline_ms, limits)
budgetsplanBudget(items, {maxSegments, maxBytes}, replaced)plan_budget(items, max_segments=, max_bytes=, replaced=)
manifest_windowsmanifestWindow(sizes, maxItems, maxBytes)manifest_window(sizes, max_items, max_bytes)
opaque_manifest_valuesopaqueManifestValue(value)opaque_manifest_value(value)
endpointsresolveEndpoint(endpoint, {allowRemoteContent, allowInsecureTransport})resolve_endpoint(endpoint, allow_remote_content=, allow_insecure_transport=)
proxiesresolveProxy(url, env)resolve_proxy(url, env)
constants, reason_catalogconstants in middleware/types.tsconstants in middleware/types.py
runtime_scenarioscreateMiddlewareRuntime({fetch}) over a scripted fetchMiddlewareRuntime(transport=...) over a scripted transport