Reason codes¶
The reason of a decision record is one of the codes below, and nothing else. The list is closed, so a denial can be counted and alerted on without parsing prose. The metrics use the same codes. The codes are in the order the server declares them.
The meanings are the policy-engine spec's own text, so a section or decision number in one points into the spec. A lab ID in a meaning is an entry of the spec's lab register, marked open or closed. It stays open until a lab test has measured the behavior it names, and a claim that cites an open entry is not yet measured.
| Reason | Meaning |
|---|---|
grants_matched |
mint: at least one declared grant selected the device; blob written. refresh: the policy in force was written into the live context; or, on a session refresh, the context was extended. identity: the caller's subject holds a grant unexpired now |
no_grants_matched |
mint: no declared grant for this principal selects the device; or, on a sweep page by scope, the page holds no device: the scope is not declared, selects no device the principal holds a grant on, or the page is past the end, which are one answer and one record carrying sweep and policy.bundle and no device (§13 decision 42 ruling 2), after the mint gate has passed. refresh: none does any more, and the live context is revoked. identity: it holds none |
username_collision |
mint: the derived username is already held by a subject in another trust domain (§2); or the directory derives a username a policy subject declares (the declared one stands, whichever came first; §13 decision 38 ruling 3, decision 40 ruling 6), or a second directory entry holds the same username under broker.directory.username_attribute (§13 decision 38); or a workload under a directory mapping whose identity subject declares no username takes from its entry one a subject declares or a second entry holds (§13 decision 41 ruling 4, plan 8b). At the broker's bind and on refresh likewise; on refresh every context of the principal is revoked. The process log names both claimants |
trust_domain_unresolved |
mint: no configured verifier accepted the credential (design document §7); or the revocation status of the certificate or one above it could not be established (a stale CRL, an OCSP responder that did not answer, an issuer no configured source covers, which is final at port49 workload, §13 decision 40 ruling 7) and broker.certificate_status.on_unavailable is refuse (§5.1.4). The API then answers 503 certificate_status_unavailable, never a final 401, and to be retried but for an uncovered issuer. A certificate whose verified chains end at both the human and the workload trust anchor, or, on a broker with no human x509 verifier, do not end at the workload anchor (§13 decision 40 ruling 2), is refused at the TLS handshake, which leaves a log line and a handshake_failures{identity_refused} count, not a decision record. On mint, refresh, identity, explain, revoke and issue also: an OIDC token none of broker.identity's verifiers accepts, or that two accept (a bad signature, an algorithm the verifier does not pin, an iss, aud or azp that does not match, an expired or not-yet-valid token, one issued longer ago than max_token_age, a human token without auth_time, a machine token from a client the verifier does not list, with another sub than the client's, or carrying a person's session claim or an idtyp other than app) (§13 decision 39); or a Kubernetes service account token the cluster's k8s verifier does not accept: a bad signature, an algorithm it does not pin, an iss it does not name, an aud other than exactly its audience, a token expired, not yet valid, ahead of the broker's clock or older than max_token_age, a typ other than JWT, a person's session claim, or a token not bound to a pod (no kubernetes.io claim, its namespace or service account not the sub's, no pod, or a secret binding) (§13 decision 49 rulings 5–7); the API answers a final 401, never certificate_status_unavailable. When the issuer's keys cannot be had (none fetched, none refreshed within jwks_max_age, or a token's kid not in the kept set and the refetch failing) the API answers 503 identity_provider_unavailable, to be retried, never a final 401. On refresh: the refresh challenge was not answered by the connection's key, or the certificate's status could not be established as on mint (503); also an OIDC refresh whose token's nonce is not the caller's unspent challenge (401 refresh_challenge_missing), or whose auth_time is before it (401 refresh_not_fresh). On issue: a missing bearer, a client certificate presented, a sign-in older than the freshness bound, a nonce not naming the CSR's key, a spent sign-in, a bad CSR, a machine token, or a sign-in that cannot be carried in a certificate (§13 decision 43 ruling 8) |
candidate_cap_reached |
mint: token.candidate_cap unspent tokens already stand for this (device, username); the mint is refused, never a redemption, and every token already issued stays usable (design document §8). A mint whose reply is lost after its commit leaves a token nobody holds; it counts toward the cap until it expires (at most 15 minutes, the longest token_ttl) |
identity_unmapped |
at the broker's bind, and on mint, identity, explain, revoke and issue: an OIDC token that verified but whose principal claim is absent, not one string, not printable ASCII with an @, outside the verifier's principal_domains, or an email without email_verified: true, or a machine token with neither azp nor client_id (§13 decision 39); or a Kubernetes service account token that verified but whose sub is not system:serviceaccount:<namespace>:<name> in the form §2 gives, or whose namespace the cluster's k8s verifier does not list in namespaces (§13 decision 49 ruling 8); or the client certificate maps to no directory entry under broker.directory.certificate_mapping; or the value the mapping reads is not in the certificate (no SID extension; zero or several UPN or email SANs); or a weak mapping's issuer is not one its issuers: pins (design §7); or a certificate the workload anchor verified names no workload: zero or several URI SANs, a URI that is not a SPIFFE ID with a path, a trust domain outside broker.identity.machines[].spiffe_trust_domains, or a leaf that is a CA (§13 decision 40), answered 401 at bind over HTTP, never as a TLS alert; or, under a workload certificate's directory mapping (§13 decision 41, plan 8b), its issuer is not one broker.identity.machines[].directory.issuers pins, no entry under base_dn holds its SPIFFE ID in the mapping's attribute, or the entry found holds that attribute with other than one value equal to the SPIFFE ID byte for byte; or, while that mapping is configured, a person's entry holds in the mapping's attribute any value beginning spiffe://, so it is a workload's (§13 decision 41 ruling 4); or the entry's principal_attribute or username_attribute is absent, has several values, is not printable ASCII, gives a principal with no @, or a username that is not already canonical or is reserved (§2); or the principal it gives is pinned to another entry, by objectGUID or entryUUID (else DN), for as long as that principal's membership record lives or until an operator runs port49 directory forget (§13 decision 38). The API answers 401 and the connection is closed. On refresh: the principal's entry is gone or no longer usable, and every context of it is revoked |
identity_ambiguous |
as identity_unmapped, when the certificate maps to two or more entries, or the server refuses the search for its size limit; or when a second entry holds the same broker.directory.principal_attribute value as the mapped entry, so two people would share one principal (§13 decision 38); or when two entries hold a workload's SPIFFE ID in the workload mapping's attribute (§13 decision 41, plan 8b); or when a uniqueness search (the username's, the principal's) does not return the entry itself, so the server's matching rule cannot prove the value is one entry's alone; or, on issue and enroll, when a directory sign-in's name matches two entries (§13 decision 47 ruling 9). Never resolved to the first entry (design §7). MUST be alertable on its own: two entries matching one identity is an impersonation path. On refresh: every context of the principal is revoked |
directory_unavailable |
at the broker's bind, and on mint, identity, explain and revoke: no directory server answered within broker.directory.query_timeout, and the membership the store holds for the principal or the certificate was verified longer ago than broker.directory.grace, or there is none, or it records the directory's refusal (grace never overrules one) (§13 decision 38); for a workload under a directory mapping, the membership stored for its SPIFFE ID (§13 decision 41 ruling 7, plan 8b), which port49 workload retries within --deadline and then exits 75. Nothing is minted. The API answers 503 with Retry-After, to be retried, and no agent session is ended. Never on a scheduled or policy refresh: a principal the directory does not answer for keeps its contexts unstamped, and context_stale takes over at the ceiling. On a session refresh (/v1/refresh), as on mint: nothing is extended. On issue and enroll, a directory-password sign-in (§13 decision 47): no server answered the entry's search or the person's bind, or the broker's own search was refused (a bind account's rights or a base_dn put wrong: the log holds the LDAP result code); no grace applies, since a password is never kept, so the sign-in is refused 503 at once (design §12) |
composition_refused |
mint: the principal matches more than one subject, and their grants together compose on this device to a session reply a joint check refuses (§10): a level above the grants' ceiling, a reply over 255 bytes, an attribute the platform does not accept, or a blob the daemon would refuse. Refused for this principal on this device alone, never a quarantine. On refresh: the live context is revoked the same way. The process log names the subjects and the check |
token_redeemed |
redeem: a presented credential matched an unspent token |
token_not_found |
redeem: the device tag matched but no unspent token for (nas, username) did — expired, spent, or mistyped, or its receipt deleted by an operator's revocation (§5.1.4) |
token_spent |
redeem/escalate: the credential matched a receipt whose sliding window (receipt_idle_ttl) or absolute cap (receipt_max_age) had passed, or whose ctx_id is no longer the live context's — the context was replaced or reclaimed since, so the stored decision describes nothing that exists (§5.6); or, on redeem or escalate, the token's store revision is at or below port49d's restore floor: an etcd restore brought it back (decision 56 ruling 1); FAIL on the wire, distinguishable only in the log |
device_mismatch |
redeem: the presented token's device tag is not this device's (design document §8) — PAP and ASCII only; under CHAP the tag is not visible and the outcome is token_not_found |
credential_mismatch |
redeem: the receipt the store returned does not carry the credential hash the lookup asked for. Since 2026-09-20 the hash is in the receipt key (§13, decision 15), so a differing credential finds no receipt and redeems normally instead; on redeem this reason now covers only a store that answered with a record it was not asked for, which is a store fault and never a normal outcome. On escalate it also covers an enable token whose principal is not the escalated context's (decision 56 ruling 11); that is a wrong or stolen credential, not a store fault, and the operator should not look at etcd for it |
receipt_replayed |
redeem: a byte-identical retransmission replayed the stored decision; not while the context it names is revoked — that is credential_revoked (§5.6). A login refused after its join replays as FAIL once the revocation is lifted |
token_kind_mismatch |
redeem/escalate: a login token presented at enable, or the reverse (§7) |
store_unavailable |
any event: the store did not answer or returned an error, or, on redeem or escalate on etcd, port49d's last read of meta/witness failed, so it cannot tell whether the store was restored (port49_redemption_restore_unjudged; decision 56 ruling 1), or, on mint at port49-broker, the mint gate is closed (port49_broker_mint_gate_closed): the replica's mark on meta/judged has not landed since its policy swap (one store round trip), or, since its start, the replica has seen no takeover done for the bundle it serves, neither its own runner's nor one meta/judged records, not in doubt (up to hold_ttl/3 and the takeover on a first start or a start after a policy edit, plus up to hold_ttl when the previous run's lease must expire first, as after a crash; none on a rolling restart; decision 36, the purge at a takeover). This is the start gate and not a store fault, and an operator should not look at etcd for it. Its 503 carries Retry-After. port49 workload retries it; the agent does not. port49 token retries a mint refused store_unavailable, waiting the reply's Retry-After (5s when it carries none) between tries, for up to 30s in all, then exits with the same code and its retry wording; it retries no other reason (owner's ruling 2026-10-15, issue #11), so a broker restart's start gate does not fail a script. On redeem and on accounting the reply is ERROR; on an authorization it is FAIL — fail closed, a break-glass account's commands included (plan 4, owner's ruling 2026-09-24), because an authorization ERROR hands the N9Kv's command to its local role checks and permits every command under IOS-XE's if-authenticated (§15, L4 (open)). Whether the device then falls through to its next method is per platform and is not what this reason promises: an authentication ERROR is final on IOS-XE and on Junos with tacplus alone, and where it does fall through (vEOS, the N9Kv) the authorization FAIL that follows leaves break-glass logged in and unable to run a command (§15, L4 (open); tests/lab/results/L4-error-*.md). So a store outage is not the break-glass case; an unreachable port49 is. The operator's escape is to stop port49d, or reject TCP 49 with a reset, so each device finds the connection refused and treats the server as down. A break-glass account then logs in with its local password, under a method list that ends in local (measured over SSH on all five images, §15 L4 (open) and L17 (open)). A firewall that drops TCP 49 silently is unmeasured as an escape (a silent server costs 5s per command on the Cisco and Arista images, L4 (open)), and so is whether commands in a session already open fall to local authorization. port49d does this itself while the store is unhealthy (owner's ruling 2026-09-24; plan 7f). On etcd it checks the store every stores.session.etcd.health.interval (or stores.directory.… when stores.session is omitted), and after close_after consecutive failed checks it closes every listener and resets held connections, idle or loaded. It is dark within close_after × interval + call_timeout of the store stopping: 5.5s at the defaults, measured at 4.8–5.5s on five images (§15 L41 (closed), tests/lab/results/L41-*.md). It opens the listeners again after open_after consecutive checks pass (§13, decision 37). Losing its hold on the store, or a token or context pool's circuit staying open or every call on one being refused for hold_ttl, remains the backstop (decision 35). Before dark, a store that does not answer, or a pool's open circuit, is answered within stores.session.packet_deadline, never left for the device to time out (what each image does then: §15 L35 (closed)). The log line is the evidence an operator needs at 3am |
context_matched |
authorize_session: context found, attributes returned |
escalation_granted |
escalate: the level was within max_privilege and the mechanism succeeded |
permit_dominates |
a permit dominated every matching deny (§5.4) |
pipe_stage_denied |
a pipeline stage matched no pipe permit (§4) |
service_unsupported |
the authorization service is not one the platform authorizes; on redeem, the authentication action or type is not one port49 serves (only login with ASCII, PAP, or CHAP) and the reply is ERROR |
enable_refused |
escalate: the subject's mechanism is none |
ceiling_exceeded |
escalate: the requested level is above max_privilege |
no_rule_matched |
nothing matched; deny by default. Also a request that is not a session authorization (§5.2) yet names no command, such as cmd= with a cmd-arg |
no_permit_dominates_deny |
a deny matched and no permit dominated it |
context_missing |
no login context for (nas, username) — port49 did not authenticate this session under that name |
username_mismatch |
the request's username differs from the one bound at redemption (§5.6). Reachable two ways, and a request under a name port49 never authenticated is neither of them — that is context_missing, because the username is the key. First, wherever the username is resolved from context rather than read off the wire: the $enab{level}$ enable path through the (nas, rem_addr) index. Second, as a store-fault guard — the context lookup selects on the username, so a store answering with another principal's record fails the re-check that follows it and is denied rather than authorized. A correct store never produces the second |
rem_addr_mismatch |
enable arrived from a different address than the bound one |
privilege_exceeded |
a command authorization's request level (session.privilege) is above the session's held level (session.held_privilege); session authorization is never denied this (§5.2) |
session_expired |
the session ceiling was reached; context retained through the grace window (§5.6). On mint: the held connection's authentication is older than session.max_session_age_default; on every endpoint but the refresh's challenge and refresh, an OIDC token's auth_time is; either way the caller must authenticate again (§13, decision 17). Per item on mint: the device's ceiling (the least max_session_age of its roles, §5.6) has passed since the connection authenticated; run port49 refresh. On refresh: the context is past its grace window and is not extended. On redeem: the token's ceiling has passed; the login is refused FAIL, the token is not spent, and no context is written (§5.6). |
context_stale |
the broker has been unreachable past staleness_ceiling, or the principal's directory membership has not been verified for that long (§5.1.3); context retained, denies until refreshed (§5.1.1, design document §9) |
credential_revoked |
the context's revoked_at is set — CRL, OCSP, back-channel logout, every grant on the device withdrawn, a subject's username changed, the principal's directory entry gone, ambiguous or renamed to a username another holds, their subjects' composition refused, the policy in force not compiling for the context, or a refresh finding a context it could not open, where the refresh record before it says which (§5.1.3, §5.6); on redeem, a login whose resulting context is revoked because its token was not minted after the revocation, or a retransmission answered while the context it names is revoked (§5.6); on mint, a revocation by principal or by this certificate stands (§5.1.4), or an OIDC logout refuses the token's session or subject, or a CRL or OCSP response names the certificate or an intermediate above it, so nothing is minted and a held connection is closed; on identity, explain and revoke, an OIDC logout refuses the caller's token; on refresh, the caller's certificate is marked, or an OIDC logout refuses the caller's token, or a CRL or OCSP response names it or an intermediate above it, so nothing is extended; on issue, a mark on the principal or an OIDC logout refuses the sign-in's token, so nothing is issued; on revoke, a revocation's own record |
device_unknown |
the source address is not in the admission trie (§10). On an unvalidated record: a reload refused, removed, renamed, re-keyed or re-platformed the device an open connection was admitted as, so that admission is no longer one port49d knows, and the connection is reset with no strike (§10, "How a daemon refreshes its snapshot"). On mint and refresh: the device is in inventory and admission refuses it — no key group admits it, two tie on it, or its key_ref is one port49d holds no key for (§10) — so no token is minted for it and its live contexts are revoked: it cannot reach port49d, and a token for it would only be a login that falls to the device's local method (tests/lab/results/quarantine-*.md). As with a quarantine, a subject with no grant on the device is answered no_grants_matched first, so the API tells no caller which devices exist |
platform_unknown |
generic_platform: the device's platform has no adapter; generic fallback applied. Written by port49d once per device per bundle (§8); never the reason of a decision |
blob_unavailable |
the referenced blob could not be resolved from the store or any cache — a store fault, alarmed (§5.1.1) — or the context no longer fits the blob: a session attribute filled from it exceeds 255 bytes, or a filled-in value carries a separator (=, *) or a control character, either of which the broker refused at mint (§6), or the reply would send a priv-lvl above the context's max_privilege, a priv-lvl that is not a level 0 to 15, or an attribute the blob's accepted_attributes does not take — compositions the load quarantines (§6, §7). On refresh: the policy in force could not be resolved or compiled for this context — an unexpected broker fault, since the reload's load (§5.1.3 step 1) compiled every subject on every device, and what failed quarantined its device or refused the policy under the joint check (§10) — so the context is revoked, failing closed; the process log names the ctx_id and the fault, and it is counted failed. Also on refresh: the context could not be opened, failing authentication (one sealed under a key port49 does not hold is left unrevoked and unrecorded, §5.1.3) — so it is revoked through the monotonic revocation mark, the record names the device and the username its key holds and no principal (the owner and ctx_id are sealed, and the index entry that led to it may be stale), and it is counted failed |
blob_platform_mismatch |
the context's blob was compiled for a platform other than the one inventory gives the device — inventory changed since the mint, or the broker and the daemon read different inventories. Its dialect and attributes are another platform's, so it is never answered from; operational, alertable beside blob_unavailable |
device_quarantined |
the device is in inventory but failed joint validation against policy (§10); the connection is closed without a reply, as for device_unknown, until fixed. On refresh, a deny recorded once per context per policy version (bundle): the live context is left as it was until the quarantine lifts |
projection_fail_closed |
authorize_session, a permit: the session on a projected platform (§8) was answered with its composition's fail-closed reply — every command denied but what the projection proved — because the projection cannot tell the device that composition faithfully (§13, decision 26). The blob carries why (fail_closed: refused_shape, configuration_mode, overflow, unwritable, secret_rule, unsound, argument_deny, a deny Junos cannot match because it names an argument, or configuration_deny, a deny of a configuration statement, which deny-commands never reaches; decision 27). Every other subject on the device is answered as before; policy test and policy diff fail on the composition |
revocation_lifted |
revoke: an operator lifted a revocation (§5.1.4). Mints by the principal or certificate are made again; a context the revocation marked stays revoked until a login whose token was minted after the revocation joins it (§5.6; §13, decision 15): for a principal's mark, after the lift; for a certificate's, by any of the principal's other certificates at any time |
api_right_missing |
revoke, explain: the caller asked to act on another principal's tokens, sessions or decisions, or on a (device, username) whoever holds it, and its subject declares no api: right that unlocks it (§2); also directory_forget, second_factor_reset, sign_in_unlock and enrollments_import, for a call under a right the subject lacks (§5.1.6). The API answers 403 api_right_missing; nothing is done |
certificate_issued |
issue: the broker issued a short-lived client certificate for the key the sign-in's nonce names (§13 decision 43 ruling 8) |
per_login_mfa_required |
mint: the device's per-login requirement (§5.1.5) applies and no proof covers it; the item carries a challenge, and nothing is minted for it. refresh: a context under a requirement no proof covers is not extended and keeps its ceiling |
per_login_mfa_proved |
per_login_mfa: a proof posted to POST /v1/mfa verified (§13 decision 45 rulings 12 and 13) |
per_login_mfa_refused |
per_login_mfa: a proof did not verify: a challenge unknown, spent, expired or another caller's; a touch signature not over this connection's exporter value; an attestation that does not chain to the configured roots, is not always-touch, or is not the session key's token's; an ID token for another principal, not fresh, without an accepted amr, not binding the challenge, or spent. The API answers 401 with the cause. An ID token decision 39 itself refuses (its signature, issuer, audience or expiry; an unmapped identity) is recorded under event per_login_mfa with decision 39's own reason (trust_domain_unresolved, identity_unmapped, …), not as this one, and is not counted in port49_per_login_mfa_total: an alert on forged tokens at /v1/mfa watches those reasons |
per_login_mfa_unavailable |
mint, refresh: the device's requirement accepts no method this broker can verify now (no hardware_key_touch.attestation_roots_file loaded, no human OIDC verifier). That device alone; alertable: a configuration fault, not a person's |
admin_touch_required |
admin_touch: an admin-level call carried no proof; a challenge was issued, nothing done |
admin_touch_proved |
admin_touch: the proof verified, and the operation's own record follows |
admin_touch_refused |
admin_touch: the proof did not verify (an unknown, spent, expired, or another caller's, connection's or request's challenge; a signature not over this exporter and request; an attestation that does not chain, is not always-touch, or is not the session key's token's), or the caller cannot prove (no client certificate). The API answers 401 with the cause. Alertable on its own, as per_login_mfa_refused is |
admin_touch_unavailable |
admin_touch: no attestation roots loaded. The API answers 503. A configuration fault, alertable |
sign_in_refused |
issue, enroll: a directory-password sign-in whose name maps to no entry under the verifier's filters or outside its principal_domains, whose code is wrong, replayed or from a clock too far off, whose password the directory refused, whose entry's state the directory refuses (expired, disabled, locked, must change), or whose second factor is another entry's (§13 decision 47). One reason to the caller on purpose, so the API never says whether the name, the password or the code was wrong; the directory's state is said past the code, or before it only when the directory gives that state to the right password alone (§13 decision 47 ruling 7; a locked account's state is never said from a password alone, L67 (closed)). The record's sign_in.failed says which. The API answers 401. Counted against the source, and against the principal once one is known |
sign_in_throttled |
issue, enroll: the sign-in's principal or source has had failures_per_principal or failures_per_source failed attempts in the window; nothing was checked (§13 decision 47 D5). The API answers 429 with Retry-After. Alert on a rise: someone is spraying |
enrollment_closed |
issue: a person with no second factor, and no enrollment window open for them; the password is not checked (§13 decision 47 D2, ruling 4). It says a name exists and has no second factor, without a bind, which is accepted (ruling 7). An operator's port49 directory reset-second-factor opens one. The API answers 403 |
enrollment_store_lost |
issue, enroll: the broker's enrollment store is lost — it has no creation mark (its file or key is missing, a new install not yet initialised by port49-broker init-enrollments included), or its file will not unseal (a lost or rotated seal key, tampering) (§13 decision 47 D7). Password sign-in is refused for everyone, before anything is counted or read; certificates already issued, tokens and IdP sign-in serve. The API answers 503. On a new install the operator runs port49-broker init-enrollments; otherwise restores the file or the etcd snapshot (port49 directory import), or runs port49 directory reset-second-factor --all. Alertable: port49_directory_enrollment_store_ok reads 0 |
second_factor_enrolled |
enroll: a person's TOTP second factor was confirmed with its first code (§13 decision 47 ruling 4). A permit; alertable |