spec(errors): register STALE_RESPONSE for cache-fallback advisories#4901
Conversation
Add STALE_RESPONSE to the canonical error-code enum as a non-fatal
advisory the seller emits alongside a populated payload when one of
its upstreams or sub-agents is unreachable and the response was
satisfied from a cache entry past the surface's freshness target.
Sibling to SERVICE_UNAVAILABLE (empty payload + fatal): same upstream
failure, opposite cache state.
- enum + enumDescriptions + enumMetadata entry (transient recovery)
- error-details/stale-response.json (served_from_cache, cache_age_seconds,
optional freshness_target_seconds, upstream {url,name}, original_error)
- error-handling.mdx System table row distinguishing from SERVICE_UNAVAILABLE
- drift-disposition entry (held-for-next-minor, target_version 3.1)
Multi-upstream cases emit one STALE_RESPONSE per affected upstream
(per-asset advisory precedent from PIXEL_TRACKER_LOSSY_DOWNGRADE),
not one aggregated entry.
Closes #4899.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Catch up the lockfile version field after the 3.1.0-beta.2 release (#4837); package.json was already at beta.2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
LGTM. Follow-ups noted below. Clean additive spec change with the right shape: new advisory rides in errors[] on a populated payload, transport-level success markers stay flipped to success, and the registry's per-asset advisory family (PIXEL_TRACKER_LOSSY_DOWNGRADE, FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE) is the cited precedent — pattern-correct.
Things I checked
- All six surfaces line up: enum entry (
static/schemas/source/enums/error-code.json:88), enumDescriptions (:171), enumMetadata (:495-498), drift disposition (scripts/error-code-drift-dispositions.json:164-168), docs row (docs/building/by-layer/L3/error-handling.mdx:518), details schema (static/schemas/source/error-details/stale-response.json). - Recovery parity:
enumMetadata.recovery: "transient"matches theRecovery: transientprose in enumDescriptions. Walker invariant held. - Distinct on the wire from
SERVICE_UNAVAILABLE— dispatch by(transport_success, errors[].code)disambiguates cleanly. None of the other transient codes ride on a success envelope. served_from_cache: const trueas a positive shape sentinel — good. Lets validators distinguish malformed/missing details from a genuineSTALE_RESPONSE.- Multi-upstream rule (one advisory per stale upstream, not aggregated) — cites
PIXEL_TRACKER_LOSSY_DOWNGRADEprecedent correctly.error.fieldnarrowing to the affected slice is the right shape. - Changeset:
minoris right for an additive enum value. Dispositionheld-for-next-minor/target_version: 3.1matches the rest of the 3.1 cluster in the dispositions file. - Two-layer model respected:
MUST NOTpromote to envelope-leveladcp_erroris the correct invariant for non-fatal-on-success-envelope advisories.
Follow-ups (non-blocking — file as issues)
transient (advisory)label vs baretransientdecoder. Docs table cell atdocs/building/by-layer/L3/error-handling.mdx:518readstransient (advisory), while the Recovery Classification table (line 527-531) enumerates exactly three values and §Retry Logic dispatches onrecovery === 'transient'. SDKs consumeenumMetadata, not the docs prose, so this isn't a wire-level break — but a naive string-equality reader of the docs row would mis-route. Smaller diff: change the cell to baretransientand move the(advisory)qualifier into the Description column.- Advisory family heterogeneous on recovery axis. Sibling advisories
PIXEL_TRACKER_LOSSY_DOWNGRADEandFORMAT_DECLARATION_V1_LOSSY_MULTI_SIZEarecorrectablein enumMetadata;STALE_RESPONSEistransient. Defensible — projection/emit advisories aren't the same shape as a runtime/dependency advisory. Worth a one-line note in the enumDescriptions block so downstream SDKs that group by family don't mis-route on this axis. - Reference storyboard scenario. Test plan flags this as deferred to the
salesagent#523cutover. Fine — the primary user-facing change is the registration itself, not the example. Land the storyboard alongside the implementation PR.
Minor nits (non-blocking)
upstreamandoriginal_errorshape-vacuous when present.static/schemas/source/error-details/stale-response.json:24-39and:40-54— both nested objects declare properties but norequired. Anupstream: {}ororiginal_error: {}would validate. ConsiderminProperties: 1(oranyOf: [{required:[url]},{required:[name]}]onupstream) so the object isn't decorative when present. The forward-compatadditionalProperties: trueposture is otherwise the right call for an extensible advisory details schema.- No trailing newline at EOF on
static/schemas/source/enums/error-code.json. Pre-existing, not introduced here — but a future PR touching this file should fix it.
Approving.
There was a problem hiding this comment.
Approving. Clean registration of a non-fatal advisory that fills the real gap between "upstream down, empty payload" (SERVICE_UNAVAILABLE) and "upstream down, cache hit served" — same architectural principle as the per-asset advisory family (PIXEL_TRACKER_LOSSY_DOWNGRADE, FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE), and the wire placement is the right shape (rides in errors[], transport-level success markers stay flipped to success, never promoted to envelope adcp_error).
Things I checked
STALE_RESPONSEappears in all three places it has to:enums.values(static/schemas/source/enums/error-code.json:88),enumDescriptions(~L170), andenumMetadata(~L495) with matchingrecovery: transient. enumDescriptions ↔ enumMetadata parity holds per the$commentcontract in the enumMetadata block.error-details/stale-response.json:served_from_cacheisconst: true+ required (positive shape sentinel),cache_age_secondsisinteger minimum: 0,additionalProperties: truematches the dominant sibling convention (rate-limited.json,conflict.json,audience-too-small.json). No clamp needed — this isn't an oracle-prone shape likebilling-not-permitted-for-agent.- Distinction from
SERVICE_UNAVAILABLEis technically sound. Seller's own service healthy + dependency unreachable + cache hit served = the right discriminant. The "sibling, opposite cache state" framing is load-bearing. - Per-upstream advisory pattern (N stale upstreams → N entries) mirrors the
PIXEL_TRACKER_LOSSY_DOWNGRADEprecedent — "SDKs SHOULD NOT collapse multiple downgrades into a single advisory entry" (error-code.jsonenumDescriptions for that code). Same contract reused, not invented. - Drift disposition:
held-for-next-minor+target_version: "3.1"satisfieslint-error-code-drift.cjs'sREQUIRES_TARGET_VERSIONrule and slots into the existing 3.1 wire-add cluster.minorchangeset is the right type for an additive enum value. - No
oneOfsurface touched —scripts/audit-oneof.mjsbaseline is not at risk. - Two-layer envelope model in
docs/building/by-layer/L3/error-handling.mdxlines 79-122 matches the prose's "MUST NOT be promoted to envelope-leveladcp_error" claim. Anchor#envelope-vs-payload-errors-the-two-layer-modelresolves.
Follow-ups (non-blocking — file as issues or fold into a follow-up)
- Broken schema link in
docs/building/by-layer/L3/error-handling.mdx:518. The new row links topathname:///schemas/v1/error-details/stale-response.json. Every sibling row in the same file useshttps://adcontextprotocol.org/schemas/v3/error-details/<name>.json(lines 264, 272, 406, 558, 565). Both thepathname:///prefix and the/v1/version segment are wrong — this will 404 in published docs. Fix tohttps://adcontextprotocol.org/schemas/v3/error-details/stale-response.jsonto match the project convention. Worth a follow-up commit before this surface ships. - Reference storyboard deferred. Test plan's unchecked item —
[ ] Reference storyboard scenario (discovery surface with one healthy + one stale upstream)— ships unvalidated against the path it documents. Acceptable for a registration-only PR; track it so thesalesagent#523cutover doesn't land before a conformance fixture exists. UPSTREAM_UNREACHABLEvsSERVICE_UNAVAILABLEfollow-up. The PR body flags this. Worth a WG decision separately; not load-bearing forSTALE_RESPONSEitself.
Minor nits (non-blocking)
- Trailing newline.
static/schemas/source/error-details/stale-response.json:57ends without a trailing newline. Sibling files in the same directory end with one. Cosmetic. freshness_target_seconds: 0semantics. The description aterror-details/stale-response.json:22could note that0is a legal value meaning "no caching contract; any cache hit is past target" —minimum: 0permits it and the relationshipcache_age_seconds - freshness_target_secondsreads cleaner with that case called out.upstream.urlformat: uriwithadditionalProperties: true. Intentional, per the PR body's "forward-compat with non-URL identifiers" framing — noted, not a defect. Thenamefield carries the internal-service case.
Safe to merge once the docs link is fixed (or a follow-up commit lands).
check-schema-links flagged the schemas/v1/ path I used in the System errors table. docs links must pin to the current major's alias (schemas/v3/) so they stay stable across releases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
LGTM. Follow-ups noted below. Clean additive registration that picks up the existing advisory-family conventions without inventing new ones.
Things I checked
- Enum value added to all three load-bearing slots:
enum[]atstatic/schemas/source/enums/error-code.json:88,enumDescriptionsat L170,enumMetadataat L494-497. Recovery classtransientmatches across the three places + changeset + docs row. - Changeset is
minor. Correct shape — additive enum value, no removal, no required→optional flip.target_version: "3.1"atscripts/error-code-drift-dispositions.json:164slots into the existing 3.1 wire-add cluster (siblings:PIXEL_TRACKER_UPGRADE_INFERRED,RETENTION_EXPIRED). - Two-layer model rule is explicit in the enumDescription: "MUST NOT be promoted to
adcp_erroron the envelope," anchored toerror-handling.mdx#envelope-vs-payload-errors-the-two-layer-model. Anchor exists atdocs/building/by-layer/L3/error-handling.mdx:79. Wire placement is unambiguous. error-details/stale-response.jsonshape:served_from_cache: {const: true}is a tight positive sentinel; required pair[served_from_cache, cache_age_seconds]matches the prose contract.additionalProperties: truematches the open-shape precedent set byrate-limited.json/conflict.json/policy-violation.json.- Per-upstream emission policy ("N stale upstreams → N entries") mirrors the per-asset precedent from
PIXEL_TRACKER_LOSSY_DOWNGRADE. Right shape. - Naming follows adjective-first / subject-state — same family as the other advisories. Names the property of the response, not the cause. The rationale in the PR body covers why
CACHED_FALLBACKwas rejected; agreed. - No
oneOfdiscriminator concern — the new file has nooneOfand the parenterror.jsonkeepsdetailsopen.
Follow-ups (non-blocking — file as issues)
- Seconds-field type drift.
cache_age_secondsandfreshness_target_secondsare typedinteger, but the existing seconds-field precedent inerror-details/rate-limited.jsonusestype: numberforretry_after_seconds. Either align tonumberfor sub-second precision (preferred), or land a follow-up to retyperetry_after_secondstointegerand pick one convention across the registry. original_error.codenamespace ambiguity. The field is a free string that may carry either an AdCP error-code enum value or a transport-level identifier (TIMEOUT,CONNECTION_REFUSED). Worth a follow-up to either document the transport identifier set explicitly or split intoadcp_code/transport_codeto keep the two namespaces from colliding.upstream.nameunconstrained. Free string today. If sub-agent identifiers stabilize into kebab-case or DNS-label conventions, a pattern would be worth adding.- Reference storyboard. Test plan's last box ("discovery surface with one healthy + one stale upstream") is unchecked and deferred to land alongside the salesagent#523 cutover. Acceptable for a spec-only registration PR — there is no normative behavior to exercise on this side yet — but the storyboard is the natural place to lock the per-upstream emission rule in fixture form, so don't let it slip.
Minor nits (non-blocking)
- Docs table recovery cell.
docs/building/by-layer/L3/error-handling.mdx:518readstransient (advisory)— every other row in that table uses a bare classification token. Either drop "(advisory)" (the description column already says "Non-fatal") or formalize the annotation across the other advisory rows (PIXEL_TRACKER_LOSSY_DOWNGRADE,FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE).
Safe to merge.
Summary
Closes #4899. Registers
STALE_RESPONSEas a non-fatal advisory error code emitted alongside a populated payload when one of the seller's upstreams or sub-agents is unreachable and the response was satisfied from a cache entry past the surface's freshness target.SERVICE_UNAVAILABLE(empty payload + fatal): same upstream failure, opposite cache state.PIXEL_TRACKER_LOSSY_DOWNGRADE,FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE): non-fatal, rides inerrors[]on the payload, transport-level success markers stay flipped to success.Why this code name
Naming follows the registry's adjective-first / subject-state convention (
INVALID_REQUEST,INVALID_STATE) and names the property of the response rather than the cause — same pattern asPIXEL_TRACKER_LOSSY_DOWNGRADEandFORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE. The cache mechanism is a seller-implementation detail surfaced viadetails; the code stays stable if a seller later serves stale via something other than a cache (replica lag, snapshot fallback).CACHED_FALLBACK/UPSTREAM_FALLBACK_SERVEDwere rejected as cause-first and verbose.Changes
static/schemas/source/enums/error-code.json— new enum value +enumDescriptionsentry (full wire-placement guidance + distinct-fromSERVICE_UNAVAILABLE) +enumMetadataentry (recovery: transient).static/schemas/source/error-details/stale-response.json— requiredserved_from_cache: true+cache_age_seconds; optionalfreshness_target_seconds,upstream: {url, name}(structured for forward-compat with non-URL identifiers),original_error: {code, message}.docs/building/by-layer/L3/error-handling.mdx— System table row with distinction fromSERVICE_UNAVAILABLE.scripts/error-code-drift-dispositions.json—held-for-next-minor,target_version: 3.1(matches the rest of the 3.1 wire-add cluster).minorbump onadcontextprotocol.Normative wire rules
STALE_RESPONSEonly when the response payload is non-empty AND derived from a cache entry past the surface's freshness target.STALE_RESPONSEentry per affected upstream rather than aggregating (per-asset advisory precedent fromPIXEL_TRACKER_LOSSY_DOWNGRADE); each entry'serror.fieldnarrows to the affected slice (e.g.,formatsfor formats from the stale upstream).errors[]and MUST NOT be promoted to envelope-leveladcp_error.Resolved open questions from #4899
STALE_RESPONSE(see "Why this code name" above).details.upstream— structuredupstream: {url, name}rather than top-levelupstream_urlstring, for forward-compat with non-URL identifiers (internal services, named sub-agents).STALE_RESPONSEentries (one per upstream), mirroring the per-asset advisory precedent.Out-of-scope / flagged separately
The original issue framed
STALE_RESPONSEas a companion toAGENT_UNREACHABLE/PROVIDER_UNAVAILABLE— those codes don't exist in the registry today. The fatal "upstream unreachable, no cache" case is currently covered bySERVICE_UNAVAILABLE. Whether the registry also needs a dedicatedUPSTREAM_UNREACHABLEcode distinct fromSERVICE_UNAVAILABLE(seller's own service down) is a separate decision; happy to spin a follow-up if WG wants the finer-grained pair.Implementation reference
bokelley/salesagent#523/#527implements the seller side of this (creative agent registry served from cache when one of N creative agents is flaky) and currently usesSTALE_RESPONSEas a non-standard code. Will align with the registered code + details schema once this lands.Test plan
node scripts/lint-error-codes.cjs— clean (103 storyboard files, 81 canonical codes)node scripts/lint-error-code-drift.cjs— clean (STALE_RESPONSEregistered asheld-for-next-minor/ target_version 3.1)npm run precommit) passed (unit + dynamic-imports + callapi-state-change + typecheck)🤖 Generated with Claude Code