Building an Issuing Authority (RTH / PTA token issuer)
Audience: Operators who run their own WBSP platform and want their own user, catalogue and billing management — i.e. you do not want wbsp.ai deciding what may be deployed on your cluster. This document is both a specification (what your service MUST emit for
wbsp-apito accept it) and a build guide (how to stand one up and test it end to end).Related: Capability-Grant Authorization (the operator switches on
wbsp-api) · Platform Operator Guide · Architecture · wire contract:specs/078-permission-to-run/contracts/token-claims.md
Table of Contents
- Why an issuing authority exists
- Concepts
- The flow
- Normative requirements
- Token reference
- The minting endpoint (CLI-facing)
- The control plane (issuer → platform)
- Build guide
- Conformance checklist
- Current limitations
1. Why an issuing authority exists
wbsp-platform gives you the means to host applications: build, route, provision databases,
scale, tear down. It deliberately does not decide which applications may run, who pays for
them, or how big they may get. Feature 078 ("Permission to Run") moves that decision out of the
platform and into a separate, replaceable service: the issuing authority.
The issuing authority is a website or server that you own. It answers one question —
"May this variant be hosted, on whose account, and within what ceilings?"
— and expresses the answer as a signed capability token. wbsp-api verifies the signature
offline against a public key you gave it, and hosts within the limits the token carries. It never
calls back to you to ask, and it holds no copy of your user database, catalogue, or billing rules.
On the public platform that role is played by the wbsp.ai website. Nothing in wbsp-api
prefers it. Point the trust anchor at your own key and your service becomes the sole authority
for your cluster; wbsp.ai has no say over what runs there.
One nuance, settled 2026-08-11 (see
notes/wbsp-subscribers-portal-prerequisites.md):
"authority" above means the hosting authority — the RTH and control tokens, grounded in
your own subscriber and billing relationships. The access licence (PTA) for software
acquired from the wbsp.ai catalogue always comes from wbsp.ai. A fully self-contained
platform running only its own catalogue plays both roles itself — but they are distinct
roles with distinct keys once the per-role trust anchors land.
What this replaces — and what it does not. Grants are an authorization layer, not an
authentication layer. wbsp-api still authenticates every deploy caller with its existing
identity check (a HAP OIDC token or a CI token) and still enforces tenant scoping. You therefore
need both: an identity provider for who is calling, and an issuing authority for what they are
permitted to run. See HOWTO-register-auth.md for the
identity half.
2. Concepts
| Term | Meaning |
|---|---|
| RTH — Request to Host | The core grant. Names the variant, who will pay, and tiered resource ceilings. One RTH authorizes one hosted application. |
| PTA — Permission to Access | A signed licence to run a variant, issued by the catalogue/software authority (wbsp.ai for its catalogue). Minted on every grant since website feature 095, public included, and it doubles as the registry pull credential (wbsp:<PTA> basic auth at the gateway, revocation re-checked per pull). Admission requires it only when the RTH says public: false; image access on the deploy-from-image path requires it always. |
| Control token | A token your issuer signs to call the platform's control plane (usage, inventory, shutdown, revoke). Same key today (the per-role anchor split is accepted, scheduled work), same audience, no grant claims. |
| WWP — who-will-pay | An opaque payer identifier in your own billing namespace (account id, tenant id, contract party). The platform stores and reports it; it never interprets it. |
| Variant | The catalogue identity of the thing being deployed. On the public platform this is a wbsp.ai catalogue variant id; on your platform it is whatever string your catalogue uses — the platform treats it as an opaque label that must match between PTA and RTH. |
Audience (aud) | The cluster a grant is valid for. wbsp-api rejects any grant whose aud is not exactly its configured WBSP_GRANT_AUDIENCE. This is what stops a grant minted for one cluster being replayed against another. |
| Trust anchor | The set of Ed25519 public keys wbsp-api trusts, plus that audience. Configured by env var — no JWKS fetch, no network dependency on your issuer. |
| observe / enforce | The per-cluster switch. observe (default) verifies and logs but never blocks or caps; enforce makes refusals, ceilings and teardown real. See capability-grants.md. |
3. The flow
┌──────────────┐ 1. login (OIDC) ┌─────────────────────┐
│ your users │──────────────────────▶│ your identity prov. │ (HAP, or any OIDC IdP)
└──────┬───────┘ └─────────────────────┘
│ 2. "deploy variant X"
▼
┌────────────────────────────────────┐
│ YOUR ISSUING AUTHORITY │ holds the PRIVATE Ed25519 signing key
│ • is this user allowed variant X? │
│ • who pays (wwp)? │
│ • what plan → what ceilings? │
└──────┬─────────────────────────────┘
│ 3. mints RTH; attaches the catalogue authority's PTA — EdDSA, aud=<cluster>
▼
┌────────────────────────────────────┐
│ deploy call → wbsp-api │ POST /api/v1/apps/{tenant}/{name}/deploy
│ body: { yaml, destination, │ Authorization: Bearer <identity token>
│ image_tag, rth, pta } │
└──────┬─────────────────────────────┘
│ 4. verify offline against WBSP_GRANT_VERIFY_KEYS (public keys only)
│ → record the grant, apply resources.*.hard as k8s limits (enforce)
▼
┌────────────────────────────────────┐
│ application hosted │
└──────┬─────────────────────────────┘
│ 5. later: your issuer polls usage / revokes
▼
DELETE /api/v1/grants/{jti} (control token) → reconcile loop tears the app downThe private key never leaves your issuer. The platform holds only public keys, so a compromised platform cannot mint permission to run anything.
4. Normative requirements
MUST / SHOULD / MAY as in RFC 2119. "The issuer" is your service.
4.1 Keys and signing
| # | Requirement |
|---|---|
| IA-1 | The issuer MUST sign with Ed25519 and emit a compact JWS with header alg: "EdDSA", typ: "JWT". Any other algorithm is rejected (wbsp-api allows EdDSA only — none and RS/HS/ES families are refused before signature checking). |
| IA-2 | The issuer MUST keep the private signing key in a secret store and MUST NOT place it on the platform, in a repo, in an image, or in an app's environment. |
| IA-3 | The issuer MUST publish the corresponding public key to the operator as kid:base64(key). Accepted encodings: raw 32-byte Ed25519 key, or PKIX/SPKI DER; base64 standard or URL-safe, padded or unpadded. |
| IA-4 | The issuer SHOULD set a kid header naming the signing key. Note: wbsp-api currently uses kid only as a label — it tries every configured key and accepts the first that verifies. Rotation therefore works by listing old and new keys simultaneously in the trust anchor; it does not depend on kid matching. |
| IA-5 | To rotate: generate a new keypair, ask the operator to add it to WBSP_GRANT_VERIFY_KEYS alongside the old one, start signing with the new key, then have the operator drop the old entry. Existing hosted apps are unaffected — verification happens at deploy time, not continuously. |
4.2 Claims
| # | Requirement |
|---|---|
| IA-6 | Every token (RTH, PTA, control) MUST carry aud equal to the target cluster's WBSP_GRANT_AUDIENCE, exactly. A mismatch is rejected with reason aud_mismatch. |
| IA-7 | Every token MUST carry a unique jti. The RTH's jti is the grant id — it is the handle used to revoke, and the platform's replay guard. A jti MUST NOT be reused. |
| IA-8 | Tokens MUST NOT carry exp. Validity is the durable hosting record plus push-revoke, not expiry. An expiring RTH would silently orphan a running application. |
| IA-9 | iat SHOULD be set to issue time. If nbf is present it is honoured with a ≤60 s clock-skew tolerance (reason not_yet_valid otherwise). Keep the issuer's clock NTP-synced. |
| IA-10 | An RTH MUST carry variant, public, wwp and resources in addition to aud/jti. contract is optional and is stored as a reference only. |
| IA-11 | When public is false, the issuer MUST also mint a PTA for the same variant and the same aud, and the deploy caller MUST present both. Missing PTA ⇒ missing_pta_for_private; differing variant ⇒ pta_variant_mismatch. |
| IA-12 | When public is true, admission needs no PTA and ignores any presented one. Image access is different: on the deploy-from-image path the PTA is the registry pull credential and is required regardless of public (see §10). |
| IA-13 | wwp MUST be meaningful in the issuer's billing namespace and SHOULD be stable for the life of the hosting relationship — it is what the platform reports back in usage/inventory, and what you will reconcile against. |
4.3 Resource ceilings
| # | Requirement |
|---|---|
| IA-14 | resources MUST be present and MUST use the tier ladder {initial, warning, soft, hard} for each dimension: instances, memory, cpu, db_size, cost. |
| IA-15 | memory, cpu, db_size MUST be Kubernetes quantity strings ("512Mi", "500m", "5Gi"). instances MUST be an integer count. cost is a number in your own rate card's units. |
| IA-16 | hard is the enforced ceiling. In enforce mode wbsp-api applies memory.hard, cpu.hard and db_size.hard as container resources plus a namespace ResourceQuota/LimitRange, and instances.hard as the replica cap. The issuer MUST NOT set a hard below what the application needs to start. |
| IA-17 | warning and soft drive threshold state reported back through the control plane; they are advisory in Phase 1. initial is the starting allocation. The issuer SHOULD keep initial ≤ warning ≤ soft ≤ hard. |
| IA-18 | cost tiers are recorded but not enforced in Phase 1 (no metering yet). Populate them anyway so the record is complete when Phase 2 metering lands. |
| IA-19 | An RTH whose hard values are all empty results in no caps applied — a silently unlimited app. Issuers that care about ceilings MUST set at least one. |
4.4 Lifecycle
| # | Requirement |
|---|---|
| IA-20 | To change limits on a running app, the issuer MUST mint a new RTH with a fresh jti and have it re-presented on a redeploy. The new grant supersedes the prior one for that app. Re-presenting the same RTH is a replay (409, reason replayed_jti, in enforce mode). |
| IA-21 | To withdraw permission (cancellation, non-payment, abuse, expiry of your own contract), the issuer MUST call DELETE /api/v1/grants/{jti} with a control token. In enforce mode the reconcile loop tears the application down within roughly a minute; the call is idempotent. |
| IA-22 | The issuer SHOULD retain its own mapping of jti → (user, variant, deployment). The platform reports jti, variant and wwp, but only the issuer knows which of its customers a grant belongs to. |
| IA-23 | The issuer MUST NOT log, persist in plaintext, or return in an error body any minted token value. The platform likewise never logs token values. |
| IA-24 | The issuer SHOULD serve minting only over TLS and only to authenticated callers (§6). |
5. Token reference
Verified offline by wbsp-api; the authoritative wire contract is
contracts/token-claims.md.
RTH — Request to Host
{
"aud": "wbsp-api",
"jti": "rth_01J8ZQ2M4T7C9V5X",
"variant": "variant_7f3a2c19",
"public": true,
"wwp": "acct_44921",
"contract": "ctr_2026_0087",
"resources": {
"instances": { "initial": 1, "warning": 2, "soft": 3, "hard": 3 },
"memory": { "initial": "256Mi", "warning": "384Mi", "soft": "512Mi", "hard": "512Mi" },
"cpu": { "initial": "100m", "warning": "300m", "soft": "500m", "hard": "500m" },
"db_size": { "initial": "1Gi", "warning": "4Gi", "soft": "5Gi", "hard": "5Gi" },
"cost": { "initial": 0, "warning": 40, "soft": 80, "hard": 100 }
},
"iat": 1769400000
}PTA — Permission to Access (private variants only)
{
"aud": "wbsp-api",
"jti": "pta_01J8ZQ2M4T7C9V5Y",
"variant": "variant_7f3a2c19",
"iat": 1769400000
}Control token
{ "aud": "wbsp-api", "jti": "ctl_01J8ZQ2M4T7C9V5Z", "iat": 1769400000 }How wbsp-api verifies (order of checks)
- Algorithm must be
EdDSA; anything else fails immediately. - Signature must verify against some key in
WBSP_GRANT_VERIFY_KEYS. If none matches ⇒no_matching_key. audmust equal the configured audience ⇒ elseaud_mismatch.nbf/iathonoured with 60 s leeway ⇒ elsenot_yet_valid.- If
public: false, the PTA is verified the same way and itsvariantmust equal the RTH's. - The RTH
jtimust not already have a live hosting record ⇒ elsereplayed_jti.
Rejection reasons (stable strings)
| Reason | Meaning |
|---|---|
no_grant | No token presented. |
malformed | Not a parseable JWT, or an unexpected claim shape. |
no_matching_key | Signature verified against none of the trusted keys (covers both a wrong key and a bad signature). |
aud_mismatch | aud is not this cluster's audience. |
not_yet_valid | nbf/iat in the future beyond the 60 s leeway. |
missing_pta_for_private | RTH says public: false and no PTA was presented. |
pta_<reason> | The PTA itself failed for <reason> (e.g. pta_aud_mismatch). |
pta_variant_mismatch | PTA and RTH name different variants. |
replayed_jti | A live hosting record already exists for this grant id. |
In observe mode each of these is logged as grant.observe would_reject reason=<x> and the
deploy proceeds unchanged. In enforce mode they become 403 (grant_rejected) or 409
(grant_replayed).
6. The minting endpoint (CLI-facing)
If your users deploy with the wbsp CLI, your issuer should expose a minting endpoint the CLI can
call after login. This is the shape agreed with the wbsp.ai website team, and adopting it keeps
you compatible with the CLI wiring when it lands (see §10 — the CLI does
not call it yet).
Endpoint: POST /api/v1/grants (path is yours to choose).
Auth: Authorization: Bearer <login session token> — reuse your existing OIDC session
validation. Do not invent a second auth path.
Request:
{ "variant": "<catalogue variant id>", "audience": "wbsp-api" }audience is the value the target cluster requires as aud. Either accept it from the caller
(deriving it from the chosen destination) or map destination → audience server-side; stamp it
verbatim into the tokens.
Response — granted:
{ "rth": "<EdDSA JWT>", "pta": "<EdDSA JWT or null>" }Response — denied (403):
{ "error": "not_permitted", "message": "…" }The authorization rule is yours. This is the point of running your own issuer. A minimal
starting rule (the one wbsp.ai is starting with): public variants are always granted with
public: true and no PTA; private variants are denied outright until the owner/collaborator check
is wired. A fuller rule consults your user model, plan tier, quota, and billing state, and picks
the resources ladder from the customer's plan.
7. The control plane (issuer → platform)
Your issuer is the only party that knows when permission should end. These endpoints let it act.
All require Authorization: Bearer <control token> signed by a trusted key. They return 503
(not_configured) if grants are not configured on that cluster, and 401 if the control token
itself fails to verify — control-token authentication is always required, in both modes.
| Method & path | Purpose |
|---|---|
GET /api/v1/tenants/{tenant}/apps | Inventory of hosted apps for a tenant. |
GET /api/v1/tenants/{tenant}/usage | Same, framed as usage/limits/threshold state. |
GET /api/v1/tenants/{tenant}/apps/{name}/usage | One app. |
POST /api/v1/grants/verify | Verify-only: does this platform accept this token, and as which role? Writes nothing. See §7.1. |
POST /api/v1/tenants/{tenant}/apps/{name}/enforce | Body {"action":"shutdown"} — stop an app. {"action":"resume"} — bring it back (§7.2). throttle returns 501. |
DELETE /api/v1/grants/{jti} | Revoke a grant; the reconcile loop tears the app down. Idempotent; 404 if no record. |
Read endpoints are always live. Enforcement actions obey the mode: in observe shutdown and
throttle return 200 with {"would": "<action>", "applied": false} and change nothing.
resume is the exception — it restores service rather than taking it away, so it executes in both
modes.
7.1 Verifying your keys without deploying anything
POST /api/v1/grants/verify
Authorization: Bearer <control token>
{ "token": "<candidate JWT>" }
200 { "verified": true, "role": "rth", "reason": "" }
200 { "verified": false, "role": null, "reason": "no_matching_key" }This is how an issuing authority proves each of its keys against a platform: the control token proves your control key by authenticating (401 vs 200), and the body proves whichever other key you send. The endpoint writes no record, mints nothing, deploys nothing, and behaves identically on every conforming platform.
It requires the control token deliberately — an unauthenticated verifier would be a free oracle
for testing stolen or forged tokens. reason is always one of the stable strings in §5; role is
null on every refusal.
A token is verified as the role its claims say it is (an wwp/resources payload is an RTH, a
bare variant is a PTA, neither is a control token) against that role's anchor. So a hosting
grant signed by the wrong authority is a refusal, not a re-attribution — which is exactly the
property the per-role anchors below exist to give you.
7.2 Resume
{"action": "resume"} brings back an application that was stopped. On this platform stop is
teardown, not pause (compute and routes go; the registry entry and data sources stay), so resume
is a redeploy from the platform's retained record — its retained image, retained pull
credential, and stored configuration. Consequences worth designing for:
- It takes deploy time, not unpause time: image pull, pod start, route creation.
- The response predicts no resulting state —
{"app", "action":"resume", "applied":true}and nothing more. What the application then is comes from reading its state, never from this acknowledgement. - Add
"destination": "<name>"when the application has several installations; without it, several is refused asdestination_ambiguousrather than guessed. - Refusals name their cause:
no_retained_image(never deployed from a published image),installation_not_found,access_denied,config_incomplete,grant_revoked(enforce mode: resume will not revive an app whose grant was revoked),deployer_unavailable(§7.3).
7.3 Platforms without a deployer (appliances)
A platform may run the verifier and control plane with no deployment machinery at all — the appliance case. There, grants are verified and revocations are recorded but not executed:
DELETE /api/v1/grants/{jti}answers{"applied": true, "executed": false}. The record is the durable fact; teardown happens if and when that platform supplies a remover. (executedis absent on deployer-backed platforms, where recording is the teardown mechanism.)shutdownandresumeanswer503 deployer_unavailable.
Present this to operators as recorded-not-torn-down. It is honest observe-grade behaviour, not enforcement.
Example inventory item:
{ "tenant": "acme", "app": "store", "grant_jti": "rth_01J8Z…",
"variant": "variant_7f3a2c19", "wwp": "acct_44921",
"limits": { "instances": {"hard": 3}, "memory": {"hard": "512Mi"} },
"usage": "unavailable", "estimated_cost": "unavailable",
"threshold_state": "ok", "mode": "enforce", "revoked": false }usage and estimated_cost are the string "unavailable" in Phase 1 — metering is Phase 2. Your
issuer should tolerate that value rather than assume a number.
Teardown timing: revocation marks the record; a reconcile loop running every minute (enforce mode only) removes the application and then deletes the record. A failed teardown is retried on the next tick, so revocation is at-least-once, not instantaneous.
8. Build guide
Step 1 — generate a signing keypair
# Private key — goes in YOUR secret store, never on the platform.
openssl genpkey -algorithm ed25519 -out grant-signing-key.pem
# Public key for the operator — PKIX/DER, base64 (accepted as-is):
openssl pkey -in grant-signing-key.pem -pubout -outform DER | base64
# …or the raw 32-byte form (also accepted; it is the tail of the DER):
openssl pkey -in grant-signing-key.pem -pubout -outform DER | tail -c 32 | base64Give the operator the public key and a key id, e.g. myco-issuer-1.
Step 2 — wire the trust anchor into wbsp-api
On the wbsp-api deployment (its wbsp.yaml env: block, or the pod env):
env:
WBSP_GRANT_VERIFY_KEYS: myco-issuer-1:<base64-public-key> # comma-separate to add more
WBSP_GRANT_AUDIENCE: cluster:<operator-chosen id> # what your `aud` must equal
WBSP_GRANT_MODE: observe # ALWAYS start hereRedeploy wbsp-api. Boot logs should show
capability-grant verification ready with the mode, the audience, and where each role's keys came
from. Full env reference and the observe → enforce cutover procedure:
capability-grants.md.
WBSP_GRANT_MODE=enforcewith a role that can verify nothing is refused at boot — the platform will not run an open enforce gate for any role.
Per-role anchors. The single variable above verifies every role, which means whoever holds that key can sign any of them. Where the roles have different authorities — the typical case, with permission-to-access coming from the catalogue website and hosting grants from the platform's own subscriber portal — give each role its own anchor:
env:
WBSP_PTA_VERIFY_KEYS: website-1:<base64-public-key> # who may authorize image access
WBSP_RTH_VERIFY_KEYS: hg-1:<base64-public-key> # who may ask this platform to host
WBSP_CONTROL_VERIFY_KEYS: ctl-1:<base64-public-key> # who may drive the control planeEach falls back to WBSP_GRANT_VERIFY_KEYS while unset, so a platform that sets none of them
behaves exactly as before. A variable that is set replaces the legacy key set for its role
rather than adding to it — otherwise the split would not actually withdraw the legacy signer's
authority, which is the entire point. Prove the result with the verify endpoint (§7.1).
Audience, per platform. WBSP_GRANT_AUDIENCE should be one value per cluster, by convention
cluster:<operator-chosen id>, so a token minted for one platform is not silently valid on every
other. Roll it out under observe, where a mismatch is a log line rather than an outage: widen the
issuer's allowlist, start stamping the new value, then require it here.
Precondition for enforce. A platform does not flip to
enforcebefore it has both the per-role anchors and its own audience value. Until then, enforcement would be resting on a shared key and a shared audience — a gate that looks closed and is not.
Step 3 — mint a token
TypeScript/Node with jose (the shape the wbsp.ai website uses):
import { SignJWT, importPKCS8 } from 'jose'
import { randomUUID } from 'node:crypto'
const key = await importPKCS8(process.env.GRANT_SIGNING_KEY_PEM!, 'EdDSA')
export async function mintRTH(opts: {
variant: string; audience: string; wwp: string; isPublic: boolean; plan: Plan
}) {
return new SignJWT({
variant: opts.variant,
public: opts.isPublic,
wwp: opts.wwp,
resources: resourcesForPlan(opts.plan), // your plan → tier ladder
})
.setProtectedHeader({ alg: 'EdDSA', typ: 'JWT', kid: 'myco-issuer-1' })
.setJti(`rth_${randomUUID()}`)
.setAudience(opts.audience)
.setIssuedAt()
// deliberately NO .setExpirationTime() — see IA-8
.sign(key)
}Python with PyJWT (pip install "pyjwt[crypto]"):
import jwt, time, uuid
from cryptography.hazmat.primitives.serialization import load_pem_private_key
key = load_pem_private_key(open("grant-signing-key.pem", "rb").read(), password=None)
rth = jwt.encode(
{
"aud": "wbsp-api",
"jti": f"rth_{uuid.uuid4()}",
"variant": "variant_7f3a2c19",
"public": True,
"wwp": "acct_44921",
"resources": {
"instances": {"initial": 1, "warning": 2, "soft": 3, "hard": 3},
"memory": {"initial": "256Mi", "warning": "384Mi", "soft": "512Mi", "hard": "512Mi"},
"cpu": {"initial": "100m", "warning": "300m", "soft": "500m", "hard": "500m"},
"db_size": {"initial": "1Gi", "warning": "4Gi", "soft": "5Gi", "hard": "5Gi"},
"cost": {"initial": 0, "warning": 40, "soft": 80, "hard": 100},
},
"iat": int(time.time()),
},
key, algorithm="EdDSA", headers={"kid": "myco-issuer-1"},
)Step 4 — test against a cluster in observe mode
Put the token in the deploy body. The identity token is still required — grants sit beside identity, not instead of it.
curl -sS -X POST "https://api.example.com/api/v1/apps/$TENANT/$APP/deploy" \
-H "Authorization: Bearer $IDENTITY_TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"yaml\": $(jq -Rs . < wbsp.yaml), \"destination\": \"aws\",
\"image_tag\": \"$TAG\", \"rth\": \"$RTH\"}"Then read the verdict from the platform logs (namespace is wbsp-<tenant>-<app> for the
wbsp-api app itself — typically wbsp-wbsp-api):
kubectl -n wbsp-wbsp-api logs deploy/wbsp-wbsp-api --tail=200 | grep grant\.
# grant.observe would_allow ← the token is correct
# grant.observe would_reject reason=aud_mismatch ← fix and retryIterate here until every real deploy logs would_allow. Nothing you do at this stage can break a
deploy: observe mode cannot refuse, cap, or tear anything down.
Step 5 — exercise the control plane
CTL=$(your-mint-control-token) # aud + jti only, same key
curl -sS -H "Authorization: Bearer $CTL" \
"https://api.example.com/api/v1/tenants/$TENANT/apps" | jq .
curl -sS -X DELETE -H "Authorization: Bearer $CTL" \
"https://api.example.com/api/v1/grants/$JTI" | jq .
# observe: {"grant_jti":"…","would":"revoke","applied":false}
# enforce: {"grant_jti":"…","applied":true} → app removed within ~1 minuteStep 6 — flip to enforce
Only after the observe logs are clean for real traffic: set WBSP_GRANT_MODE=enforce, redeploy
wbsp-api. From then on, invalid grants are refused, resources.*.hard becomes real Kubernetes
limits, and revoked grants cause teardown. Reverting to observe is a single env change and a
redeploy — no application redeploys required.
9. Conformance checklist
A minimum viable issuer:
- Ed25519 keypair generated; private key in a secret store; public key +
kidhanded to the operator. - RTH minting with
aud, uniquejti,variant,public,wwp,resources,iat, noexp. - Resource ladder derived from your plans, with
hardvalues an app can actually start within. - Private variants: PTA minted for the same
variant, or the request denied. -
jti → customer/deploymentmapping stored on your side. - Revocation path: control-token minting +
DELETE /api/v1/grants/{jti}wired to your cancellation / non-payment / abuse flows. - Limit changes mint a new RTH (fresh
jti), never re-present an old one. - No token value is ever logged or returned in an error.
- Verified end to end against a cluster in observe mode before enforce is switched on.
Additionally, for a production platform:
- Key rotation procedure documented and rehearsed (two keys in the anchor during overlap).
- Inventory reconciliation: periodically compare
GET /api/v1/tenants/{tenant}/appsagainst your own records to catch drift (grants you revoked that are still hosted, or vice versa). - Clock sync (NTP) on issuer hosts.
- Tolerates
"unavailable"forusage/estimated_cost.
10. Current limitations
Phase 1 of feature 078 — know these before designing around it.
- The
wbspCLI does not fetch or attach grants yet. Grants reachwbsp-apionly when the caller of the deploy API placesrth/ptain the request body. A portal that callswbsp-apidirectly works today; CLI wiring is a platform follow-up. Until it lands, clusters must remain in observe mode for CLI users, or CLI deploys will be refused once enforce is on. - Anchors are per ROLE, not per issuer; no federation. Since the per-role split (§8 step 2) a key can be trusted for permission-to-access without being trusted for hosting grants or the control plane. Within a role, though, every configured key is still equally trusted for every tenant, and any valid control token may read or revoke any grant on that cluster. Issuer → tenant scoping and per-issuer revocation remain deferred. Do not hand a control-token signing key to a party you would not trust with the whole cluster.
kidis not used to select a key — the verifier tries all configured keys (see IA-4).- No cost enforcement or metering.
costtiers andusage/estimated_costare recorded or reported as"unavailable"; nothing is measured or billed by the platform (Phase 2). throttleis not implemented — it returns501.resumenow works (§7.2), as a redeploy from the platform's retained record; it is not an unpause and takes deploy time.- A resumed application's state is not reported here. The resume acknowledgement says the redeploy began, never what the application became; a running-state read is a separate capability.
- Revocation is eventual, on a one-minute reconcile tick, and enforce-mode only.
- The PTA is also the pull credential (correction 2026-08-11 — this bullet previously said
the opposite). At the wbsp.ai registry gateway a PTA is presented as
wbsp:<PTA>basic auth and revocation is re-checked on every pull; the deploy-from-image path requires it for public and private variants alike. Identity-based image access rules are described in variant-deploy-repositories.md.
See also
- Capability-Grant Authorization — the operator-side switches, env vars and observe → enforce cutover.
specs/078-permission-to-run/contracts/token-claims.md— authoritative wire contract.specs/078-permission-to-run/contracts/control-api.md— control & status API contract.notes/wbsp-website-pta-rth-minting-api.md— the request sent to the wbsp.ai website team; a worked example of one issuer's initial rule set.