Skip to content

S2S Relay Envelope

Applies to: every AnkaSecure error body produced by a request that an internal service forwarded to another internal service
Content-Type: application/problem+json (RFC 9457)

Some AnkaSecure operations are served by more than one internal service. A tenant-scoped key operation, for example, enters at the Admin API, which forwards it over an internal service-to-service (S2S) call to the Core API. When the internal service rejects the request, the Admin API has to turn that rejection into an error body of its own.

This page is the normative description of how that body is composed. It exists so a client never has to infer the rule — or, worse, parse an error document out of a detail string.

The one rule

detail is always a single human-readable sentence. It is never a JSON document.

Everything a client needs is a named member of the problem body — read directly, never re-parsed out of a string. On the Admin API — the responding service on every relay path described here — extension members (requestId, failedEntries, …) sit one level down inside an extensions object rather than at the top level; that is a fixed, documented location, not a document folded into a value. Placement differs by service, so see Error Index & Overview.

Which members can come from the upstream

Exactly four members are ever taken from an upstream body — type, title, detail and status. That is the complete allow-list: an upstream extension member cannot appear in the response by accident, because the internal view of an upstream problem has no field for one.

Member Source Why
type the upstream, on the arms listed below Both services emit into the same https://docs.ankatech.co/errors/<slug> registry, so the slug identifies the same condition regardless of which service detected it.
title the upstream, on the same arms Short human label paired with type.
detail the upstream, on a wider set of arms (see below) The sentence describing the specific rejection.
status the upstream See the propagatable set below.
instance the responding service (the one you called) — always The path of your request. The upstream's internal route is never republished — it belongs to a URI space you do not address, and it can embed identifiers such as a kid.
extensions.requestId the responding service — always The correlation identity of your request. The upstream's own correlation id is never copied, so a body never carries two competing identities.
timestamp the responding service — always When the responding service produced this body.

title and detail are sanitised before they are re-presented: control characters and line/paragraph separators are stripped, and the text is capped at 512 characters; a sentence that had to be shortened ends in […], so a truncated sentence is distinguishable from a complete one.

On the arms that propagate a type, that type is re-presented only when it is a member of the shared https://docs.ankatech.co/errors/ registry — the same registry this page's index documents. A scheme check alone would not be enough: any https origin could then emit a registry-shaped slug and forge a classification you branch on. Anything outside the registry prefix — a relative reference, http:, javascript:, data:, a userinfo-bearing look-alike authority, or simply another https origin — routes the whole document to the degenerate branch. The upstream detail only arms are independent of that check: they sanitise and relay the sentence whatever the upstream type is, because those arms never publish an upstream type in the first place.

How much is propagated depends on the operation

Full propagation of all four members is not universal. Each operation decides per status how much of the upstream rejection it re-presents, and there are three behaviours:

Behaviour What you receive Where it applies
Full propagation upstream type, title, detail and status The generic client-error arm of key creation, key rotation and rotation admissibility — that is, every 4xx those three operations do not handle specifically. Also the specifically-handled 409 arm of all four key routes — key creation, key rotation, keystore import and key import — and three specifically-handled 422 arms: the rotation-policy refusal on key rotation, keystore import, and key import.
Upstream detail only upstream detail; type and title are the responding service's own generic slug for the status Rotation admissibility on 404; keystore import on a 4xx other than 409/404/422; keystore analysis on any 4xx; key import on a 4xx other than 409/404/422.
No propagation an operation-specific sentence authored by the responding service; the upstream body is discarded 404 on key rotation, keystore import and key import. Also any 409 on the four key routes whose upstream body carries nothing propagatable — see the 409 fall-through, which is a fall-through from row 1, not a behaviour those routes choose.

A key-protection refusal pre-empts all three rows. Four problem types — not configured, unavailable, misconfigured and rate limited — are recognised by the responding service before any of those three behaviours is reached, and are re-presented with their own status and header posture rather than as that operation's relayed view, generic sentence or generic slug. So a 409 on keystore import or key import is relayed from the upstream only when the upstream type is not one of those four, and a 429 on keystore import, key import or keystore analysis is relayed as key-protection-rate-limited with its Retry-After rather than reduced to a 400. Every other status on those arms behaves exactly as the table states.

An algorithm-not-admitted refusal pre-empts rows 2 and 3 on a 400. When the Core API answers a key creation, key rotation, key import or keystore import with that type, the responding service recognises it from the upstream type alone and re-presents it with the upstream type, title and detail — so on the two import routes, whose generic 400 behaviour is upstream detail only, this one 400 keeps its own type. Its two extension members cross the hop too: algorithm and refusedBy are part of the type's published contract, and each is re-minted — algorithm as the responding service's own catalogue name, refusedBy only when it has the vocabulary's shape — or omitted, never copied as the upstream's bytes. Every other 400 behaves exactly as the table states.

A 402 deployment-license refusal likewise pre-empts rows 2 and 3. When the Core API refuses an orchestrated operation because the deployment's license has lapsed, the responding service recognises the 402 ahead of its generic arm and re-presents it fully, under Deployment License Expired, on every relay site — key creation, key rotation, rotation admissibility, keystore import, keystore analysis and key import alike. It is never reduced to a 400 on the three arms whose generic behaviour is upstream detail only, and never rendered as a 502: the deployment's license state is a decision, not an outage, and an operator shown "Core API is unavailable" would look for the wrong fault. Only the operations an expired deployment refuses can produce it; reads relayed through the same client are unaffected.

Two consequences of row 1's "do not handle specifically" are worth stating outright, because they are the cases a reader is most likely to assume the wrong way round. Key creation has no dedicated 404 arm in the relay, so an upstream 404 reaches its generic arm and is fully propagated — it is not one of the admin-authored sentences. Read "arm" here as a handled-status branch of the relay, not as an OpenAPI declaration: key creation does declare a 404 of its own in the published contract (the addressed tenant does not exist), and that one is the responding service's own refusal rather than anything relayed. Likewise rotation admissibility has no dedicated 409 arm: its 409 is fully propagated like the other four routes', but because it arrives on the generic arm rather than a dedicated one, it degenerates to upstream-rejected-request rather than to that operation's own sentence when the upstream body is unusable.

A full propagation arm propagates the four members only when the upstream body is itself a usable RFC 9457 document; when it is not, the same arm falls through to the degenerate branch (or, for a structured keystore-import or key-import 422, to its counting-sentence refinement; or, for a 409, to the 409 fall-through, which is not the degenerate branch). The upstream detail only arms are unaffected by that fall-through — they never publish an upstream type in the first place.

The consequence worth planning for: on an upstream detail only arm, type is a generic slug for the status (for example .../errors/not-found) while detail describes a specific upstream condition. Classify on type and show detail — but do not expect the slug to be as specific as the sentence on those arms.

Independently of all three, instance, extensions.requestId and timestamp are always the responding service's own, and detail is always a single sentence.

The 409 fall-through

409 is the one propagatable status whose fall-through is not the degenerate branch. When an upstream 409 body carries nothing propagatable — absent, blank, not JSON, or missing type or detail — the responding service keeps its own sentence for that operation and publishes it under the generic .../errors/conflict slug. It does not emit upstream-rejected-request, which is where the other propagatable statuses land.

A key-protection refusal never reaches this fall-through, and the missing-detail case is the one where that matters. The four key-protection types are recognised from the upstream type alone, before a usable detail is required beside it, so a key-protection 409 whose sentence did not survive keeps its own type and status rather than degenerating to the generic slug. This is the same pre-emption stated above for all three behaviours, restated here because the enumeration in the previous paragraph would otherwise read as covering it.

The consequence worth planning for: on a 409, the generic slug reaches you in two different situations — the upstream classified the conflict as generic, or the upstream classified nothing at all. A narrower slug, on the other hand, is always the upstream's own classification and is always safe to branch on, because the fall-through can only ever produce the generic one. So treat .../errors/conflict on a 409 as "a conflict, unclassified" before concluding anything more specific.

detail is worth reading — it is the sentence to show an operator and to put in a log line — but it is not a member to branch on. Its wording is not a contract member and may be reworded without notice, so code that matches on it breaks silently on a rewording that no version bump announces. Where the generic slug leaves you unable to tell two conditions apart, resolve it by re-reading the target resource and letting its state decide, never by matching the sentence.

Which statuses are relayed

Only these five upstream statuses are re-presented as a client fault:

Every other upstream status becomes Upstream Service Unavailable (502). That deliberately includes 401 and 403: an internal service refusing the platform's own S2S credential is a platform-side misconfiguration, and reporting it as a 401, a 403, or a client 400 would misattribute it to your request.

Relayed rejection — example

{
  "type": "https://docs.ankatech.co/errors/rotation-purpose-violation",
  "title": "Rotation Purpose Violation",
  "status": 422,
  "detail": "The requested rotation would change the key purpose, which the tenant policy forbids.",
  "instance": "/api/v3/admin/tenants/2f1c9d84-6b2e-4d3a-9f57-0a1b2c3d4e5f/keys/my-mlkem-key/rotate",
  "timestamp": 1751500800,
  "extensions": {
    "requestId": "550e8400-e29b-41d4-a716-446655440000"
  }
}

This is a full propagation arm: type, title, detail and status are the Core API's. instance, timestamp and extensions.requestId are the Admin API's. Nothing in the body reveals the Core API's internal route.

The degenerate branch

If the upstream body cannot be trusted, the responding service does not guess and does not forward bytes. It emits its own error instead. This branch is taken when the upstream body:

  • is absent, empty, or not valid JSON;
  • is not an RFC 9457 object;
  • carries no type, or a type outside the shared https://docs.ankatech.co/errors/ registry (which also covers a relative reference, a non-https scheme, a userinfo-bearing look-alike authority, a value carrying a control character, and a value over 512 characters);
  • carries a detail that is blank after sanitisation.

Refinement — a structured keystore-import rejection

One refinement applies to a 422 whose body is a structured keystore-import result. Such a body is not a problem document, so the checks above route it here — but it does carry a usable per-entry failure list. When at least one entry is extracted, the responding service emits an admin-authored counting sentence instead of the "no usable problem description" wording, which would otherwise deny the structure the same response publishes:

The key-management service rejected the request. This response carries N failed keystore entries under extensions.failedEntries.

N is the number of entries published under extensions.failedEntries — a count of this response, never an upstream total, since the list is capped at 50 — and the sentence reads entry in the singular when N is 1. type and title are still the responding service's own. When no entry can be extracted, the branch is unchanged and the sentence in the example below applies.

Degenerate rejection — example

{
  "type": "https://docs.ankatech.co/errors/upstream-rejected-request",
  "title": "Upstream Rejected Request",
  "status": 422,
  "detail": "The key-management service rejected the request and returned no usable problem description.",
  "instance": "/api/v3/admin/tenants/2f1c9d84-6b2e-4d3a-9f57-0a1b2c3d4e5f/keys/import-keystore",
  "timestamp": 1751500800,
  "extensions": {
    "requestId": "550e8400-e29b-41d4-a716-446655440000"
  }
}

The status is still the upstream's (it is within the propagatable set), but every text member is the responding service's own. No upstream byte appears in the body. This example carries no failedEntries member, which is why detail is the "no usable problem description" wording rather than the counting sentence above.

Structured failure lists

When a rejection has per-item detail — a keystore import in which every entry failed, for example — that detail is published as a named extension member at extensions.failedEntries, never as text inside detail. The list is capped at 50 entries so the response stays bounded. Note that this member is populated on the degenerate branch too: the per-item data is projected from the upstream body's own failure list even when that body carries nothing usable as a problem document — and when it is populated, detail is the counting sentence described above rather than the "no usable problem description" wording. See the failedEntries example on Unprocessable Entity.

What this means for a client

  1. Read type to decide what happened. It is the stable, machine-readable classifier. On the upstream detail only arms above it is a generic slug for the status rather than a specific condition, so pair it with the status rather than assuming it is always the narrowest label.
  2. Show detail to a human. Do not match on its wording — it is prose and may be reworded.
  3. Never parse detail as JSON. It is a sentence, in every branch, including the degenerate one.
  4. Use instance and extensions.requestId (or the X-Request-Id header) for support correlation. They always describe your own request.
  5. Read extension members from extensions, not from the top level.