Skip to content

spec(errors): register STALE_RESPONSE for cache-fallback advisories#4901

Merged
bokelley merged 3 commits into
mainfrom
bokelley/choose-error-code
May 21, 2026
Merged

spec(errors): register STALE_RESPONSE for cache-fallback advisories#4901
bokelley merged 3 commits into
mainfrom
bokelley/choose-error-code

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Summary

Closes #4899. Registers STALE_RESPONSE as 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.

  • Sibling to SERVICE_UNAVAILABLE (empty payload + fatal): same upstream failure, opposite cache state.
  • Sibling to the existing per-asset advisory family (PIXEL_TRACKER_LOSSY_DOWNGRADE, FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE): non-fatal, rides in errors[] 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 as PIXEL_TRACKER_LOSSY_DOWNGRADE and FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE. The cache mechanism is a seller-implementation detail surfaced via details; the code stays stable if a seller later serves stale via something other than a cache (replica lag, snapshot fallback). CACHED_FALLBACK / UPSTREAM_FALLBACK_SERVED were rejected as cause-first and verbose.

Changes

  • static/schemas/source/enums/error-code.json — new enum value + enumDescriptions entry (full wire-placement guidance + distinct-from SERVICE_UNAVAILABLE) + enumMetadata entry (recovery: transient).
  • static/schemas/source/error-details/stale-response.json — required served_from_cache: true + cache_age_seconds; optional freshness_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 from SERVICE_UNAVAILABLE.
  • scripts/error-code-drift-dispositions.jsonheld-for-next-minor, target_version: 3.1 (matches the rest of the 3.1 wire-add cluster).
  • Changeset: minor bump on adcontextprotocol.

Normative wire rules

  • Sellers MUST emit STALE_RESPONSE only when the response payload is non-empty AND derived from a cache entry past the surface's freshness target.
  • When no cached entry exists or the cache hit is within freshness target, sellers MUST NOT emit this code.
  • Multi-upstream cases emit one STALE_RESPONSE entry per affected upstream rather than aggregating (per-asset advisory precedent from PIXEL_TRACKER_LOSSY_DOWNGRADE); each entry's error.field narrows to the affected slice (e.g., formats for formats from the stale upstream).
  • Buyers MUST treat as non-fatal and SHOULD surface staleness to operators or end users where relevant.
  • The advisory rides in errors[] and MUST NOT be promoted to envelope-level adcp_error.

Resolved open questions from #4899

  1. NameSTALE_RESPONSE (see "Why this code name" above).
  2. Scope of applicability — applicable to any tool that has a caching layer in the seller's implementation (not limited to discovery). The schema is generic; tool-specific guidance can land later if patterns emerge.
  3. details.upstream — structured upstream: {url, name} rather than top-level upstream_url string, for forward-compat with non-URL identifiers (internal services, named sub-agents).
  4. Multiple stale upstreams — N separate STALE_RESPONSE entries (one per upstream), mirroring the per-asset advisory precedent.

Out-of-scope / flagged separately

The original issue framed STALE_RESPONSE as a companion to AGENT_UNREACHABLE / PROVIDER_UNAVAILABLE — those codes don't exist in the registry today. The fatal "upstream unreachable, no cache" case is currently covered by SERVICE_UNAVAILABLE. Whether the registry also needs a dedicated UPSTREAM_UNREACHABLE code distinct from SERVICE_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 / #527 implements the seller side of this (creative agent registry served from cache when one of N creative agents is flaky) and currently uses STALE_RESPONSE as 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_RESPONSE registered as held-for-next-minor / target_version 3.1)
  • JSON validation on both modified/created schemas
  • Local pre-commit (npm run precommit) passed (unit + dynamic-imports + callapi-state-change + typecheck)
  • Reference storyboard scenario (discovery surface with one healthy + one stale upstream) — deferred; will land in a follow-up alongside the salesagent#523 cutover

🤖 Generated with Claude Code

bokelley and others added 2 commits May 21, 2026 10:16
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>
aao-release-bot[bot]
aao-release-bot Bot previously approved these changes May 21, 2026

@aao-release-bot aao-release-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the Recovery: transient prose 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 true as a positive shape sentinel — good. Lets validators distinguish malformed/missing details from a genuine STALE_RESPONSE.
  • Multi-upstream rule (one advisory per stale upstream, not aggregated) — cites PIXEL_TRACKER_LOSSY_DOWNGRADE precedent correctly. error.field narrowing to the affected slice is the right shape.
  • Changeset: minor is right for an additive enum value. Disposition held-for-next-minor / target_version: 3.1 matches the rest of the 3.1 cluster in the dispositions file.
  • Two-layer model respected: MUST NOT promote to envelope-level adcp_error is the correct invariant for non-fatal-on-success-envelope advisories.

Follow-ups (non-blocking — file as issues)

  • transient (advisory) label vs bare transient decoder. Docs table cell at docs/building/by-layer/L3/error-handling.mdx:518 reads transient (advisory), while the Recovery Classification table (line 527-531) enumerates exactly three values and §Retry Logic dispatches on recovery === 'transient'. SDKs consume enumMetadata, 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 bare transient and move the (advisory) qualifier into the Description column.
  • Advisory family heterogeneous on recovery axis. Sibling advisories PIXEL_TRACKER_LOSSY_DOWNGRADE and FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE are correctable in enumMetadata; STALE_RESPONSE is transient. 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#523 cutover. Fine — the primary user-facing change is the registration itself, not the example. Land the storyboard alongside the implementation PR.

Minor nits (non-blocking)

  1. upstream and original_error shape-vacuous when present. static/schemas/source/error-details/stale-response.json:24-39 and :40-54 — both nested objects declare properties but no required. An upstream: {} or original_error: {} would validate. Consider minProperties: 1 (or anyOf: [{required:[url]},{required:[name]}] on upstream) so the object isn't decorative when present. The forward-compat additionalProperties: true posture is otherwise the right call for an extensible advisory details schema.
  2. 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.

aao-release-bot[bot]
aao-release-bot Bot previously approved these changes May 21, 2026

@aao-release-bot aao-release-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_RESPONSE appears in all three places it has to: enums.values (static/schemas/source/enums/error-code.json:88), enumDescriptions (~L170), and enumMetadata (~L495) with matching recovery: transient. enumDescriptions ↔ enumMetadata parity holds per the $comment contract in the enumMetadata block.
  • error-details/stale-response.json: served_from_cache is const: true + required (positive shape sentinel), cache_age_seconds is integer minimum: 0, additionalProperties: true matches the dominant sibling convention (rate-limited.json, conflict.json, audience-too-small.json). No clamp needed — this isn't an oracle-prone shape like billing-not-permitted-for-agent.
  • Distinction from SERVICE_UNAVAILABLE is 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_DOWNGRADE precedent — "SDKs SHOULD NOT collapse multiple downgrades into a single advisory entry" (error-code.json enumDescriptions for that code). Same contract reused, not invented.
  • Drift disposition: held-for-next-minor + target_version: "3.1" satisfies lint-error-code-drift.cjs's REQUIRES_TARGET_VERSION rule and slots into the existing 3.1 wire-add cluster. minor changeset is the right type for an additive enum value.
  • No oneOf surface touched — scripts/audit-oneof.mjs baseline is not at risk.
  • Two-layer envelope model in docs/building/by-layer/L3/error-handling.mdx lines 79-122 matches the prose's "MUST NOT be promoted to envelope-level adcp_error" claim. Anchor #envelope-vs-payload-errors-the-two-layer-model resolves.

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 to pathname:///schemas/v1/error-details/stale-response.json. Every sibling row in the same file uses https://adcontextprotocol.org/schemas/v3/error-details/<name>.json (lines 264, 272, 406, 558, 565). Both the pathname:/// prefix and the /v1/ version segment are wrong — this will 404 in published docs. Fix to https://adcontextprotocol.org/schemas/v3/error-details/stale-response.json to 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 the salesagent#523 cutover doesn't land before a conformance fixture exists.
  • UPSTREAM_UNREACHABLE vs SERVICE_UNAVAILABLE follow-up. The PR body flags this. Worth a WG decision separately; not load-bearing for STALE_RESPONSE itself.

Minor nits (non-blocking)

  1. Trailing newline. static/schemas/source/error-details/stale-response.json:57 ends without a trailing newline. Sibling files in the same directory end with one. Cosmetic.
  2. freshness_target_seconds: 0 semantics. The description at error-details/stale-response.json:22 could note that 0 is a legal value meaning "no caching contract; any cache hit is past target" — minimum: 0 permits it and the relationship cache_age_seconds - freshness_target_seconds reads cleaner with that case called out.
  3. upstream.url format: uri with additionalProperties: true. Intentional, per the PR body's "forward-compat with non-URL identifiers" framing — noted, not a defect. The name field 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>

@aao-release-bot aao-release-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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[] at static/schemas/source/enums/error-code.json:88, enumDescriptions at L170, enumMetadata at L494-497. Recovery class transient matches 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" at scripts/error-code-drift-dispositions.json:164 slots 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_error on the envelope," anchored to error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model. Anchor exists at docs/building/by-layer/L3/error-handling.mdx:79. Wire placement is unambiguous.
  • error-details/stale-response.json shape: served_from_cache: {const: true} is a tight positive sentinel; required pair [served_from_cache, cache_age_seconds] matches the prose contract. additionalProperties: true matches the open-shape precedent set by rate-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_FALLBACK was rejected; agreed.
  • No oneOf discriminator concern — the new file has no oneOf and the parent error.json keeps details open.

Follow-ups (non-blocking — file as issues)

  • Seconds-field type drift. cache_age_seconds and freshness_target_seconds are typed integer, but the existing seconds-field precedent in error-details/rate-limited.json uses type: number for retry_after_seconds. Either align to number for sub-second precision (preferred), or land a follow-up to retype retry_after_seconds to integer and pick one convention across the registry.
  • original_error.code namespace 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 into adcp_code / transport_code to keep the two namespaces from colliding.
  • upstream.name unconstrained. 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)

  1. Docs table recovery cell. docs/building/by-layer/L3/error-handling.mdx:518 reads transient (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.

@bokelley bokelley merged commit 4adb65a into main May 21, 2026
26 checks passed
@bokelley bokelley deleted the bokelley/choose-error-code branch May 21, 2026 14:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec(errors): register STALE_RESPONSE for cache-fallback served when upstream is unreachable

1 participant