Troubleshooting¶
Symptom first. Each row names the cause that is most often behind it, and the evidence that distinguishes it from the others.
| Symptom | The likely cause | Where to look |
|---|---|---|
every request is 401 |
the credential is not verifiable: a wrong audience, a stale JWKS, a clock skew beyond the leeway | the audit event's method and code; the JWKS cache's refresh behaviour |
every request is 403 |
the policy engine is unreachable, so every decision is degraded — and degraded is a refusal | the decision audit events; the engine's own logs |
one route is 403 for everyone |
the permission is declared but absent from the policy | the engine's decision log for that permission |
| a route is reachable by anyone | the permission is missing from the catalog, so the declaration did not take | the catalog at startup; the route walk in the example's tests |
404 for a tenant that exists |
the resolver chain did not find it: check the order, the base domain, the claim name | JDLIB_RESOLVERS__*, and the resolver named in the span |
403 TENANT_SUSPENDED |
the tenant's lifecycle state refuses the request — this is the gate working | the tenant's state in the control plane |
| a cross-tenant read returns an empty page | a reference resolved outside the tenant — this should be 409 INVALID_REFERENCE |
the resource type's resolver |
| rows are visible that should not be | the application connects as the owner role, or the policy is not installed | jdlib rls verify; JDLIB_RLS__APP_ROLE |
503 on readiness but the app serves |
the control plane is unreachable from this process | the DSN, the network policy, the database's own health |
| fast refusals after a dependency recovers | the breaker is open: it is doing its job until its cooldown probes | the breaker's transition metric |
| retries are not happening | the retry budget is spent — by design, so a struggling dependency is not amplified | the budget's exhaustion metric |
429 under load |
the concurrency gate refused the work | the gate's refusal metric; the limit is per process |
| a deploy dropped requests | readiness did not flip before the drain | the ShutdownReport |
| a denial has no audit event | the sink is unreachable, or the surface's boundary is not wired | the sink's failure metric |
| traces are missing attributes | the attribute is not on the allow-list | jdlib.security.tracing |
| a graph query is refused | the session has no tenant, or an identifier was passed as query text | jdlib.graph.tenant, jdlib.graph.query |
| an MCP tool's arguments are rejected | the client sent what the tool advertises but not what it enforces | the tool's input model |
| the docs build fails in CI | a page is unlisted, a link is broken, or a generated page is stale | mkdocs build --strict; scripts/docs-inventory.py --check |
The first three moves for any incident¶
- Read the audit trail, not the logs: it records the decision, the tenant and the request identity for allows and refusals.
- Check
/readyzrather than assuming a dependency is healthy — and check which dependency it names. - Check the refusal's code, not its message: the codes are stable (error codes), and each one narrows the layer.