Remote Bundle Wire Protocol
This document specifies the HTTP protocol used by SAPL nodes to fetch .saplbundle
files from remote servers. Third-party servers can implement this protocol to serve
bundles to SAPL nodes.
There are two consumption modes over the same bundle format and trust model. Single
mode (regular polling or long-poll) fetches one client-declared pdpId. Realm mode
points the client at a server-managed realm and lets it discover a dynamic set of
bundles from a signed index.
URL Convention
Bundles are addressed by convention:
{baseUrl}/{pdpId}
Example: https://pap.example.com/bundles/production
The pdpId is a path segment appended to the configured base URL. No query parameters
are used.
Regular Polling
Request
GET {baseUrl}/{pdpId} HTTP/1.1
Host: pap.example.com
If-None-Match: "v42-sha256-abc123"
Accept: application/octet-stream
Authorization: Bearer eyJhbGciOiJSUz...
| Header | Required | Description |
|---|---|---|
If-None-Match |
After first fetch | ETag from the previous response. Omitted on first request. |
Accept |
Yes | Always application/octet-stream. |
| Auth header | If configured | Custom header name and value (e.g., Authorization: Bearer ...). |
Response: Bundle Changed (200)
HTTP/1.1 200 OK
Content-Type: application/octet-stream
ETag: "v43-sha256-def456"
Content-Length: 12345
<.saplbundle ZIP bytes>
| Header | Required | Description |
|---|---|---|
Content-Type |
Recommended | Should be application/octet-stream. The client does not validate this header. |
ETag |
Recommended | Opaque version identifier. Used by the client for If-None-Match on subsequent requests. |
Content-Length |
Recommended | Size of the response body in bytes. |
The response body is the raw .saplbundle ZIP archive bytes.
Response: Not Modified (304)
HTTP/1.1 304 Not Modified
ETag: "v42-sha256-abc123"
The server returns 304 when the bundle has not changed since the ETag provided in
If-None-Match. No response body is sent.
Long-Poll Mode
Long-poll uses the same request format as regular polling. The server behavior differs:
- If the bundle has changed since the provided ETag: respond immediately with
200 OKand the new bundle. - If unchanged: hold the connection open until either:
- The bundle changes: respond
200 OKwith the new bundle. - The server’s hold timeout expires: respond
304 Not Modified.
- The bundle changes: respond
The client reconnects immediately after receiving either response.
Server Timeout Advertisement (Optional)
Servers supporting long-poll MAY include an informational header:
X-Long-Poll-Timeout: 30
This indicates the maximum hold time in seconds. It is informational for client-side logging and diagnostics only. The client does not use this value to configure its own timeouts.
Realm Mode (Multi-Bundle)
Regular and long-poll modes fetch a single, client-declared pdpId. Realm mode points
the client at a realm, a server-managed dynamic set of bundles, and lets it discover
and track that set from a signed index. The index attests membership and binding:
which pdpIds exist and which URL each one is bound to. For every listed entry the client
runs an autonomous single-mode fetch loop against the bound URL, so version updates flow
through the ordinary bundle endpoints without any index change.
URL Convention
A realm exposes three URL families, all derived from one server-side “current” pointer per
pdpId:
| URL | Mutability | Purpose |
|---|---|---|
{baseUrl}/{indexPath} |
mutable | The signed realm index. |
{baseUrl}/bundles/{pdpId} and .../{pdpId}/latest |
mutable | The current bundle for a pdpId. The index normally binds here. |
{baseUrl}/bundles/{pdpId}/{configId}.saplbundle |
immutable | One exact bundle version. Used by the index to pin or roll back. Cacheable forever. |
A bundle is signed once per (pdpId, configId) and served byte-for-byte from both the
immutable and latest URLs, so ETags stay stable and immutable URLs cache forever.
The Realm Index
Request
GET {baseUrl}/{indexPath} HTTP/1.1
Accept: application/jose
If-None-Match: "1751632200000"
Authorization: Bearer eyJhbGci...
Prefer: wait=25
The Prefer: wait=<seconds> header is optional and requests long-poll of the index.
Response (200)
The body is a compact JWS signed with EdDSA.
HTTP/1.1 200 OK
Content-Type: application/jose
ETag: "1751632260000"
<base64url(header)>.<base64url(payload)>.<base64url(signature)>
Decoded protected header and payload:
{ "alg": "EdDSA", "kid": "prod-2026", "typ": "sapl-realm-index" }
{
"realm": "acme",
"sequence": 1751632260000,
"issuedAt": "2026-07-04T12:31:00Z",
"bundles": [
{ "pdpId": "orders",
"url": "https://pap.example.com/realms/acme/bundles/orders" }
]
}
| Field | Description |
|---|---|
realm |
The realm identifier. The client refuses an index whose realm does not match its own. |
sequence |
A monotonic counter. The client refuses any index whose sequence is not strictly greater than the last accepted, defeating rollback/replay of an old, validly-signed index. |
bundles[].pdpId |
Stable identity, the key the client loads and removes under. |
bundles[].url |
Absolute URL of the bundle endpoint the client monitors for this pdpId. |
The URL is the binding, and its mutability class is the policy. Binding to the mutable
latest endpoint means the client tracks whatever the server publishes there. Binding to
an immutable version URL pins the pdpId to that exact version, which is how a deliberate
rollback is expressed: it arrives as a signed rebinding through the index.
The server returns 304 Not Modified when the index is unchanged. Under Prefer: wait,
it holds the request until the realm changes or the wait elapses. The index only changes
when membership or a binding changes, so index traffic is light.
Index Signature and Trust
The index verifier pins the algorithm exactly as bundle manifests do:
- The JWS
algMUST beEdDSA. Any other value, includingnone, is refused. - The
kidresolves the trusted public key through the same node key configuration used for bundle manifests, so one trust anchor covers both. - After the signature verifies, the client enforces the
realmmatch and a strictly greatersequence.
When signature verification is disabled (development), the signature check is skipped but the realm and sequence checks still apply.
Reconciliation
On each verified, newer index the client diffs the listed bindings against its running fetch loops:
| Case | Action |
|---|---|
New pdpId |
Start a fetch loop on the bound URL, load the bundle. |
Same pdpId, changed url |
Replace the fetch loop with one bound to the new URL and reset its conditional-request and freshness state, so a pin to an older version loads. |
pdpId absent from the index |
Stop the fetch loop and remove the configuration. |
Same pdpId and url |
Nothing, the running loop keeps monitoring. |
An unverifiable, wrong-realm, or stale index is a no-op. The client keeps its current configuration and never mass-removes on a malformed index. A valid index with an empty bundle list is a legitimate operation and empties the realm. Each fetch loop retries transport failures with bounded exponential backoff independently, so one unreachable bundle never blocks the others.
Version Freshness
Within one binding, the client rejects any bundle whose manifest signing time (created,
which is covered by the bundle signature) is older than the currently loaded bundle’s.
This defeats the replay of an older, validly signed bundle at a mutable URL. A signed
rebinding through the index resets the check, so deliberate pins and rollbacks load. The
consequence is a clean discipline: the latest endpoint only ever moves forward, and
going backwards requires the signed index.
Single vs Realm Integrity
Single mode authenticates bundle content via the manifest signature, but membership is client-configured and version currency is only server-asserted via ETag. Realm mode adds a monotonic, signed attestation of membership and binding, plus the version freshness check within each binding. Choose realm mode when the set of bundles is server-managed or when replay and rollback protection matter.
Error Responses
| Status | Meaning | Client Behavior |
|---|---|---|
200 |
Bundle returned | Parse, verify signature, load configuration. |
304 |
Not modified | Keep current bundle, re-poll after interval. |
301, 302, 307, 308 |
Redirect | Follow redirect (if enabled in client config). |
401, 403 |
Authentication failure | Log error, retry with exponential backoff. |
404 |
pdpId not found on server | Log error, retry with exponential backoff. |
5xx |
Server error | Log error, retry with exponential backoff. |
Transport-level error responses (401, 403, 404, 5xx, and connection timeouts)
are transient. They trigger retry with exponential backoff, the client never stops
retrying, and it resumes normal operation after the server recovers. A 200 OK that
carries a definitively invalid bundle is not a transport error. It is handled as a
configuration error rather than retried in silence (see Bundle Validity).
Bundle Format
The response body for 200 OK is a .saplbundle file: a ZIP archive with only
root-level files. Subdirectories and unknown file names are rejected.
| File | Required | Purpose |
|---|---|---|
pdp.json |
Yes | PDP configuration: combining algorithm, variables, and a configurationId that uniquely identifies this configuration version. Never contains secrets. |
*.sapl |
Yes | SAPL policy documents. |
secrets.sealed.json |
No | Sealed PDP-level secrets. Scalar leaves are ENC[...] tokens encrypted to an X25519 recipient key, structure and key names stay readable. |
ext-<name>.json |
No | Cleartext extension data. Named JSON for consumers other than the PDP, for example gateway upstream configuration. |
ext-<name>-secrets.sealed.json |
No | Sealed extension secrets for the same <name>. |
critical-extensions.json |
No | A JSON array of extension names the consumer MUST be able to process, e.g. ["upstreams"]. |
.sapl-manifest.json |
If signed | Cryptographic manifest with SHA-256 content hashes and an Ed25519 signature covering every other file. |
Secrets Sealing
A secrets file carries its sealing state in its name: a .sealed.json name holds
sealed content, a plain .json name holds cleartext. A bundle never mixes both
states, and a sealed-named file whose content is not sealed is rejected. Cleartext
variants (secrets.json, ext-<name>-secrets.json) exist for development setups
only and require the consumer’s explicit unencrypted-secrets opt-in. Sealing runs
before signing, so the manifest signature covers the ciphertext.
Extensions and Criticality
Extension files let a bundle carry configuration for consumers other than the PDP.
The consumer receives them alongside the policy configuration, keyed by extension
name. critical-extensions.json follows the JOSE crit idea: a consumer without
support for a listed extension MUST reject the whole configuration rather than
silently ignore it, while unlisted extensions it does not know are ignored. Every
listed name must have a payload in the bundle (ext-<name>.json or
ext-<name>-secrets.sealed.json or both), otherwise the bundle is rejected. The
critical set is covered by the manifest signature, so it cannot be stripped in
transit.
Bundle Validity
A bundle that is fetched successfully but is definitively invalid is handled as a
configuration error rather than being retried in silence. This covers an invalid or
missing signature, a malformed archive, a missing or unreadable pdp.json, secrets
that cannot be unsealed, and a missing payload for a critical extension.
For each such bundle the client reports a configuration error for the affected pdpId.
It keeps serving the last successfully loaded configuration and marks that pdpId
STALE. When there is no last-good configuration to keep, the pdpId goes to ERROR
and fails closed, so its decisions are INDETERMINATE. The failure and a
human-readable reason appear in the client health status, and the bundle is
re-evaluated when its content changes (a new ETag). This is distinct from a transient
transport error such as a timeout or a 5xx, which the client retries silently and
which does not emit a configuration error.
Security Considerations
Transport Security
Servers SHOULD use HTTPS. The client supports TLS via standard Spring Boot SSL configuration.
Bundle Signatures
Bundles fetched over HTTP are verified using Ed25519 signatures by default. The signature verification is performed client-side using the public key configured on the node. This provides end-to-end integrity verification independent of transport security.
A server serving an unsigned or wrongly signed bundle to a client with mandatory
signature verification causes the client to reject that bundle. This is a definitive
configuration error, not a transient transport failure. The client never loads the
bundle, keeps serving its last-good configuration, marks the pdpId STALE (or ERROR
and fails closed if there is no last-good), and reports the reason in its health status.
See Bundle Validity.
Authentication
The protocol supports a single configurable HTTP header for authentication. The header name and value are sent on every request. Common patterns:
| Pattern | Header Name | Header Value |
|---|---|---|
| OAuth2 Bearer Token | Authorization |
Bearer eyJhbGci... |
| Static API Key | X-Api-Key |
sk-abc123... |
| Basic Auth | Authorization |
Basic dXNlcjpwYXNz... |
Implementing a Compatible Server
A minimal compatible server must:
- Serve
.saplbundleZIP files at{baseUrl}/{pdpId}with200 OK. - Keep bundle responses under 256 MiB (the client rejects larger responses).
- Return
404for unknown pdpIds. - Optionally: support
If-None-Match/ETagfor conditional requests with304responses.
For long-poll support, the server must additionally:
- Hold the connection open when the bundle has not changed.
- Respond with
200 OKwhen the bundle changes during the hold. - Respond with
304 Not Modifiedwhen the hold timeout expires.
For realm mode, the server must additionally:
- Serve the current bundle at
{baseUrl}/bundles/{pdpId}(and/latest), and immutable bundles at{baseUrl}/bundles/{pdpId}/{configId}.saplbundlefor pinning and rollback. - Serve a compact-JWS realm index at
{baseUrl}/{indexPath}, signed withEdDSAusing the same key as the bundle manifests, whosebundles[].urlbind eachpdpIdto the endpoint the client monitors, normally thelatestURL. - Increase the index
sequencemonotonically on every membership or binding change, and keep oldconfigIdversions addressable so the index can pin or roll back to them.
Static file servers (Nginx, S3, CDN) inherently support the regular polling mode with ETag-based conditional requests.