Migrating from 0.5 to 0.6
Grantex 0.6 adds purpose-bound grants, spend caps, ES256 signing and an OAuth profile for grant tokens. Most of it is additive: an integration that does not use the new features keeps working. This guide lists every change that can alter the behaviour of an existing deployment, SDK integration or resource server, grouped by feature. The CHANGELOG has the full list of additions. No step in this guide invalidates an outstanding token. Upgrading keeps every token issued before 0.6 verifying, in the auth service and in the SDK verifiers, and every key change below publishes the new key before it signs and keeps the old key published until the tokens it signed have expired. Package version numbers are set when the release is cut. “0.5” and “0.6” here mean the Grantex protocol, auth service and SDK behaviour before and after these changes.At a glance
Upgrade order
- Upgrade verifiers first. Update the SDKs (or your own verifier) on every resource server so it accepts RS256 and ES256 and reads the standard claims. 0.6 SDKs read both claim forms.
- Upgrade the auth service. Migrations apply on start. Defaults keep RS256 signing and keep issuing the legacy claim aliases, so 0.5 verifiers keep working.
- Adopt the new features (purposes, object-form manifests, caps) one at a time. Each denies calls it cannot evaluate.
- Before 0.7, move every resource server to the standard claims (0.6
SDKs already read them). Then set
GRANT_TOKEN_LEGACY_CLAIMS=false(the 0.7 default). This changes only tokens issued from then on; tokens already issued keep their aliases until they expire. Switch to ES256, if you want it, only after step 1 is complete everywhere, following the rotation steps below.
Tool manifest schema 0.6
Reference:spec/manifest-0.6.md and
spec/manifest-0.6.schema.json.
Unchanged: a manifest whose tools are all permission strings
("get_case": "read") loads and enforces exactly as in 0.5.
Strict validation of object-form manifests. A manifest that declares
$schema or uses the object form for any tool is validated against the 0.6
schema. Loading raises ManifestValidationError (a ValueError in Python)
for:
- an unknown key at any level;
requires_decision: trueon areadtool;four_eyes_onwithoutrequires_decision;- empty
caps,cost_units,allowed_purposesorfour_eyes_on; - a malformed purpose pattern, cap, unit or decision name.
from_file / fromFile and
load_manifests_from_dir / loadManifestsFromDir reject a JSON or YAML
manifest that repeats a key inside one object (duplicate key "<key>" in manifest file). 0.5 silently kept the last value, which could drop a
requires_decision. Action: load your manifest files once with the 0.6 SDK
and fix any duplicate it reports.
Behaviour change — reserved tool name. cost_units can no longer be a
tool name, in either manifest form, because grant caps use it for the
cost-unit budget. Action: rename such a tool and the scopes and tools lists
that name it.
enforce() fails closed on declarations. In 0.6:
- A tool that declares
allowed_purposesis denied (purpose_not_allowed) unless the grant carries a matching purpose. - A tool with
requires_decisionalways returnsdecision_required, because decision grants are not accepted yet. - A tool with
capsorcost_unitsis denied withcap_exceeded/meter_unavailableunless the client has a caps meter.
enforce() allows. Action: add declarations only together with the grants,
decision flow or meter that satisfy them.
Additive: denied results carry reason_code / reasonCode, sub_reason
/ subReason and details. reason is unchanged.
Purpose-bound grants
Reference: Purpose-bound grants. Unchanged: requests withoutpurpose issue the same grants as before, and
tools without allowed_purposes ignore purpose entirely.
purpose is validated. POST /v1/authorize (and AuthorizeParams.purpose
in the SDKs) rejects with 400 INVALID_PURPOSE:
- a purpose outside the vocabulary (
aml.cdd.onboarding,aml.cdd.ongoing,aml.screening,procurement.vendor_onboarding,payments.payout) that is not a privatex-<org>.<term>; - a purpose sent without any
tool:<connector>:<permission>scope.
authorization_details. A purpose-bound grant’s tokens carry
one urn:grantex:tools:v1 entry per connector. The purpose is kept on refresh
and inherited by delegated grants.
Behaviour change — malformed authorization_details denies every call.
enforce() denies every call on a token whose authorization_details it
cannot read unambiguously (token_invalid / malformed_authorization_details):
- a claim that is not an array;
- an entry without a string
type; - a tools entry with an unknown key or a wrong type;
- two entries for one connector.
authorization_details send well-formed entries.
Behaviour change — the tools list is enforced. When a tools entry has
tools, a tool not listed is denied (tool_not_granted /
not_in_authorization_details), even if a scope covers it.
Database: migration 095_purpose_bound_grants.sql adds nullable purpose
columns to auth_requests, grants and audit_entries.
Spend caps
Reference: Caps and metering. Unchanged: tools and grants withoutcaps or cost_units are not metered.
Caps need a meter. A call to a tool or grant that declares caps is denied
with cap_exceeded / meter_unavailable when the client has no meter, or its
backend is unreachable. Action: configure Grantex(caps_meter=...) /
new Grantex({ capsMeter }) with the Redis or Postgres backend before
declaring caps.
- Redis needs 6.0 or later and
maxmemory-policy noeviction. - Postgres needs the tables from
SCHEMA_SQL/CAPS_SCHEMA_SQLorensure_schema()/ensureSchema(), and a periodicprune(). - There is no automatic failover between backends.
cost_units. Any of the following
denies every call on the connector (token_invalid /
malformed_authorization_details) instead of being ignored:
- a wildcard key such as
screen_*; - an unknown window;
- a count outside 0–2147483647.
- A cap of
0disables a tool. - A per-case cap needs
case_id/caseId(case_required). - Units are reserved as the last check and are not refunded when the
provider call fails. Use
refund_unsent()/refundUnsent()only when the call was never sent. - A tool with
cost_unitsbut no budget on the grant is allowed when a meter is configured.
caps_mode="warn" / capsMode: 'warn' to report
would_deny / wouldDeny without denying.
ES256 signing
Reference:SPEC.md §6.1 and §14, and
self-hosting Section 7.
Unchanged: JWT_SIGNING_ALG defaults to RS256, and the same
RSA_PRIVATE_KEY keeps signing.
Behaviour change — key ids and the kid strictness they required. A 0.5
auth service published its RSA key under grantex-YYYY-MM, the month the
process started, and verified its own tokens without looking at kid. 0.6
verifies by kid and key type everywhere, which on its own would have broken
tokens across a month boundary or across instances started in different
months. 0.6 therefore:
- names each key by its RFC 7638 thumbprint (
grantex-rs256-…,grantex-es256-…), the same on every instance; - verifies an RS256 token whose
kidis anygrantex-YYYY-MM, or that has nokid, with the legacy key —RSA_PRIVATE_KEY, or the key named byJWT_LEGACY_KID_KEY; - also publishes the legacy key under
grantex-YYYY-MMfor the current month and the previousJWT_LEGACY_KID_MONTHS - 1months (default 13 in total), so SDK verifiers that select keys bykidfind it; - keeps signing under the legacy
kidforSIGNING_KEY_ACTIVATION_DELAY_SECONDS(default 900) after start, so resource servers holding a JWK Set fetched from a 0.5 instance keep accepting new tokens until they refresh it.
JWT_LEGACY_KID_MONTHS if you issued grants that live longer
than a year before upgrading. Do not remove the RSA key while pre-0.6 tokens are
valid, and set JWT_LEGACY_KID_KEY to its thumbprint kid if it stops being
RSA_PRIVATE_KEY. There is deliberately no setting to rename a key’s kid:
changing a published kid would invalidate the tokens signed under it.
Resource servers must accept ES256 before an issuer switches. A deployment
may now set JWT_SIGNING_ALG=ES256. It then signs grant tokens, OAuth access
tokens, verifiable credentials, SD-JWTs, agent passports, principal sessions
and wallet authorizations with an EC P-256 key.
The 0.6 SDK verifiers accept both algorithms. A verifier that pins RS256 —
your own code, or the Grantex CLI, gemma, mpp and conformance packages
(FINDINGS G-8) — rejects those tokens. Action: allow exactly
['RS256', 'ES256'], and select the JWK Set key by kid.
Behaviour change — SDK verifiers match key type to algorithm. Python,
TypeScript and Go verifiers reject:
- an RS256 token whose
kidnames an EC key, and the reverse; - a key published with a different
alg; - a key whose
useis notsig; - an ES256 key not on P-256;
alg: noneand HS256 (as before).
kty, crv, alg and use.
The new algorithms / Algorithms option can narrow the list, never widen it.
Behaviour change — signing keys are validated at start. The auth service
refuses to start with:
- an RSA key shorter than 2048 bits;
- an
EC_PRIVATE_KEYthat is not P-256; - a key in the wrong setting (for example an RSA key in
EC_PRIVATE_KEY); - a
JWT_VERIFICATION_PUBLIC_KEYSentry with private members, an unsupportedalg, a legacygrantex-YYYY-MMkid, or akidused by a different key; - a
JWT_LEGACY_KID_KEYthat names no configured RSA key; - with the postgres key store, a
SIGNING_KEY_RETIRED_GRACE_SECONDSshorter thanMAX_GRANT_LIFETIME_SECONDS(a warning when the latter is unset).
SSO_STATE_SECRET is unset and there is no RSA_PRIVATE_KEY, EC_PRIVATE_KEY
or VAULT_ENCRYPTION_KEY to derive one from. Deployments with
RSA_PRIVATE_KEY derive the same key as in 0.5.
RSA_PRIVATE_KEY must be PKCS#8 (-----BEGIN PRIVATE KEY-----), as before.
Rotating keys without invalidating tokens (details in
self-hosting Section 7):
-
Env key store.
- Publish the new public key in
JWT_VERIFICATION_PUBLIC_KEYS, or set the other algorithm’s private key setting, and wait at leastSIGNING_KEY_ACTIVATION_DELAY_SECONDS. - Switch the private key, keeping the old public key in
JWT_VERIFICATION_PUBLIC_KEYS(andJWT_LEGACY_KID_KEYfor the old RSA key). - Remove the old key only after its tokens have expired.
kid. - Publish the new public key in
-
Postgres key store.
node dist/cli/rotate-signing-key.js [--alg ES256]publishes a pending key that signs only afterSIGNING_KEY_ACTIVATION_DELAY_SECONDS. The previous key is then retired, its private key erased, and it stays published forSIGNING_KEY_RETIRED_GRACE_SECONDS. The legacy key stays published for the alias window.
096_platform_signing_keys.sql). Set SIGNING_KEY_STORE=postgres and keep the
key settings for the first start:
- The env signing key becomes the stored active key, with the same
kid. - The other env keys are stored as retired public keys.
- The RSA key keeps its legacy kid marker.
VAULT_ENCRYPTION_KEY
and bound to their kid. Erasing a retired key does not remove copies in dead
tuples, WAL, replicas or backups; see self-hosting Section 7.
The DID document lists every platform signing key.
Standard grant token claims
Reference:spec/grant-token-0.6.md and
SPEC.md §6.
Grant tokens now validate with a stock OAuth or JOSE library using only
standard semantics. Each 0.5 claim maps to a standard claim:
The compatibility flag.
- 0.6:
GRANT_TOKEN_LEGACY_CLAIMSdefaults totrue. The auth service issues the 0.5 aliases next to the standard claims, with identical values, so a 0.5 verifier keeps working. - 0.7: the default becomes
false, and tokens carry only the standard claims. - A later release removes the flag.
The verifiers read the standard claim first and fall back to an alias only
when the token lacks the standard claim — in practice, tokens issued by a 0.5
auth service. They warn for each alias used and list the aliases in
legacy_claims_used / legacyClaimsUsed / LegacyClaimsUsed. With the
option off, aliases are ignored and typ must be at+jwt.
Action for resource servers using the SDKs:
- Upgrade.
- Watch for the deprecation warning. It means a token came from an issuer that is not yet on 0.6.
- Set
legacy_claims=False/legacyClaims: false/StandardClaimsOnly: trueonly when no warning has appeared for longer than your longest grant lifetime (so no pre-0.6 token can still be presented), and no grant with a whitespace scope remains (below). Until then keep the default: it accepts both forms.
jose:
packages/sdk-ts/tests/standard-claims.test.ts and
packages/sdk-py/tests/test_standard_claims.py, which validate tokens issued by
the auth service (spec/examples/grant-token-0.6.issued.json). A custom
verifier that must accept pre-0.6 tokens during the transition reads scp
when the token has no urn:grantex:grant. Integrations in this repository
that still read the aliases directly are listed in FINDINGS G-12.
Action for operators: keep the default. Setting
GRANT_TOKEN_LEGACY_CLAIMS=false affects only tokens issued afterwards; tokens
already issued keep their aliases until they expire, and 0.6 verifiers read
both forms. Set it only once every resource server reads the standard claims,
because a verifier that reads only the aliases cannot use the new tokens. The
auth service logs a deprecation notice at start while it is true.
Proof of possession. cnf.jkt is carried as before, and the SDK verifiers
return it without enforcing it. To enforce it, verify the DPoP proof yourself
and pass its key thumbprint as proof_jkt / proofJkt / ProofJKT; add
require_proof_of_possession / requireProofOfPossession /
RequireProofOfPossession to fail closed when none is passed.
Behaviour change — nested act on delegation. A delegated token’s act
now nests the parent token’s act (RFC 8693). A second-level delegation
carries {"sub": <parent agent>, "act": {"sub": <grandparent agent>}}, where
0.5 carried only the parent. The chain is stored on the grant (migration
098_grant_actor_chain.sql), so refreshed tokens keep it. Grants delegated
before the migration refresh with the parent agent only, as before. act.sub
is the delegating agent, not the current actor as in the usual RFC 8693
reading; the current actor is client_id. Action: read act.sub for the
delegating agent; walk nested act for earlier ones.
Behaviour change — disagreeing claims are refused. The auth service
(invalid_claims) and the SDK verifiers refuse a 0.6 token (one with
urn:grantex:grant) where:
- a standard claim and its alias disagree, for example
scopeandscp; acthas no stringsubor is nested more than 10 deep;urn:grantex:grantis not an object.
urn:grantex:grant; its scope was a join of scp that is lossy for
scopes containing whitespace, so its scp is read and the two are not
compared. Outstanding pre-0.6 tokens therefore keep verifying.
Behaviour change — null and mistyped claims. The SDK verifiers refuse a
token where any of these is present with a null value, where 0.5 treated
null as absent:
urn:grantex:grantor any of its members;scope,scp,act,cnf,client_id,audorauthorization_details;- a legacy alias.
client_id that is not a non-empty string, an aud that is
not a string or an array of strings, and an authorization_details that is not
an array. Tokens issued by the auth service never contain such values.
Behaviour change — whitespace in scopes.
- New grants.
POST /v1/authorizerefuses a scope containing whitespace with400 INVALID_SCOPE. Action: use scope names without spaces. - Existing grants. Grants created earlier with such a scope keep working:
token exchange, refresh and delegation still issue tokens, and outstanding
tokens verify. Because
scopeis space-delimited and cannot hold such a scope, these tokens omitscopeand always carryscp, whateverGRANT_TOKEN_LEGACY_CLAIMSsays. A verifier reading standard claims only (legacy_claims=False, and the 0.7 default) refuses them rather than read a different scope set. Action: re-issue such grants with space-free scopes before switching verifiers to standard-only. - Other entry points. Agent registration and consent bundles still accept such scopes (FINDINGS G-11).
enforce() returns
decision_required for a tool listed in the grant’s urn:grantex:decision:v1
entry, even when the manifest does not declare requires_decision. A
malformed decision entry denies every call on the token. A delegated grant
keeps the decision entries of the connectors it keeps.
TypeScript type change. In GrantTokenPayload, agt, dev and scp are
optional and marked deprecated. scope, aud, cnf, act and
urn:grantex:grant are added. Code that relied on agt being a string needs
a check, or better, should read VerifiedGrant, which is unchanged apart from
new optional fields (act, cnf, audience, legacyClaimsUsed; Go also
AuthorizationDetails).
Database migrations
All three are additive and apply automatically on start. (097 is used by the
decision-grant work, not by these changes.)
New settings
Checklist
- Every resource server verifies with a 0.6 SDK or allows
RS256andES256withkid-based key selection. - Manifest files load with the 0.6 SDK: no duplicate keys, no tool named
cost_units. - Object-form declarations are paired with purposes on grants, a caps meter, or an accepted
decision_required. - Clients that push
authorization_detailssend well-formed entries. - The auth service starts with the configured signing keys; the RSA key that signed pre-0.6 tokens stays configured (or is named by
JWT_LEGACY_KID_KEY) until those tokens expire. - Key rotations publish the new key first and keep the old key until its tokens expire.
- No grant with a whitespace scope remains before verifiers read standard claims only.
- No deprecation warning for legacy claims appears on any resource server.
- Custom verifiers read
scope,client_id,actandurn:grantex:grant. -
GRANT_TOKEN_LEGACY_CLAIMS=falseis planned before 0.7, after every resource server reads the standard claims.