Audit and observability
Every check emits a decision event with outcome, reasons, actor and delegation; permdock/otel adds a span per check; HTTP denials are RFC 9457 Problem Details.
An authorization library that cannot tell you what it decided, for whom, and why, is a liability at incident time. PermDock treats observability as part of the decision: on('decision') delivers the full Decision with the subject and the arguments, permdock/otel turns the same data into spans and a counter, and HTTP adapters emit RFC 9457 Problem Details that carry the denial reasons and alternatives to the caller.
on('decision')
const permdock = await createPermDock(policy, user, { actor, delegation })
permdock.on('decision', (event) => audit.write(event))Every can, decide, assert and filter emits one event per evaluated check (filter emits one event with the counts, not one per row; simulate emits one simulate event). The event is a frozen JSON object:
type DecisionEvent = {
type: 'decision'
at: string // ISO timestamp
outcome: 'granted' | 'denied' | 'approval-required'
permission: string // 'post.delete'
scope: string // 'post:delete'
resource: { type: string; id?: string } // id from the resource's `id` field; absent for collection actions
subject: {
principal: { id: string; roles: string[]; tenant?: string } | null
actor?: { id: string; kind: string }
delegation?: { scopes?: string[]; authorizationDetails?: unknown[] }
}
tenant?: string // the active tenant for this request
membership?: Membership // the membership that supplied the matched role; absent for global roles
via?: string // 'group:<id>' | 'team:<id>' when the membership was inherited
matched?: { role: string; permission: string } // granted / approval-required
denials?: Array<{ role: string | null; reason: string }> // denied
alternatives?: string[] // permission keys
token?: string
trusted: boolean // whether the data crossed a boundary and was validated
source: 'can' | 'decide' | 'assert' | 'filter' | 'endpoint' | 'adapter' | 'approval'
adapter?: string // 'next' | 'hono' | 'mcp' | 'ai-sdk' | 'eve' | 'openai' | ...
phase?: 'requested' | 'resolved' // only on source 'approval'; see the approval store
}The event contains the result. permix's hooks gave you { path, data } before the check and nothing after, so an audit log could record that a check happened but not what it returned; issue #38 asked for the result and stayed open. PermDock's event is emitted after evaluation and includes outcome, denials, matched and alternatives, which is what an audit trail, an anomaly detector or a "why can't I see this" support tool needs.
tenant, membership and via make a tenant-scoped audit log a filter and let a sink answer "which team grant let this happen" (tenancy). A tenant-defined custom role appears through its resolved declared role in matched.role; the custom name is on the membership.
Handlers are fire-and-forget: they run after the decision is computed, a throwing handler is reported to on('error') and never changes the outcome, and async handlers are not awaited by can or decide. If your sink needs backpressure, buffer in the handler.
Principal and actor in every event
For agent traffic the event distinguishes the human (principal) from the agent (actor) and records the authority it used (delegation). "The reporting agent deleted post 42 for Alice under scope post:delete, matched by the admin role" is one event, not a join across three logs. See subject.
Example sink
permdock.on('decision', (event) => {
if (event.outcome === 'granted' && Math.random() > 0.1) return // sample routine grants
queue.push({
at: event.at,
outcome: event.outcome,
permission: event.permission,
resourceId: event.resource.id,
principalId: event.subject.principal?.id ?? null,
actorId: event.subject.actor?.id ?? null,
reasons: event.denials?.map((d) => d.reason) ?? [],
adapter: event.adapter,
})
})Register the handler once where you create the PermDock (the server factory file), or pass it to the adapter's createPermDock options so every request-scoped instance inherits it.
DecisionSink
A handler is enough for one process. When events must leave the process, the adapters take a sink option typed against a small interface, with an in-memory default, so the destination is pluggable the same way the approval store is (ADR 0021):
interface DecisionSink {
write(events: readonly DecisionEvent[]): Promise<void> | void // batched; never awaited by decide
flush?(): Promise<void> // called at request end when the runtime has waitUntil
}
import { memorySink } from 'permdock'
const sink = memorySink({ capacity: 10_000 }) // default; ring buffer, readable in tests and `permdock doctor`
createPermDock(policy, { subject, sink }) // every request-scoped instance writes to itImplementations that ship or are documented:
| Sink | Where | What it does |
|---|---|---|
memorySink() | core | Ring buffer; the default; what @permdock/testing asserts against |
permdock/otel | optional entry | Spans and metrics from the same events (below) |
permdock/cloud | optional entry | Batched delivery to the PermDock Cloud decision log with retention and per-actor queries (Cloud adapter) |
| Your own | a few lines | write inserts a batch into your database or queue; the recipe is the example sink above wrapped in the interface |
A sink receives decision events and the approval events that the store emits when a request is created and resolved; the three share token, so a sink can reconstruct an approval flow without a second log. A failing sink is reported to on('error') and never changes an outcome.
Sink recipes and the OCSF projection
Sinks for Sentry, PostHog, Datadog, Axiom, Better Stack, a Postgres table or a queue are recipes, not entries: each is the interface above with write calling the vendor SDK or a driver (ADR 0023, the general rule). The destinations fall into three groups with three different ingestion shapes:
| Destination | Examples | Ingests | Recipe |
|---|---|---|---|
| LLM observability | Langfuse, LangSmith, Braintrust, Datadog LLM Observability, Sentry AI monitoring, PostHog LLM analytics | OpenTelemetry GenAI spans (execute_tool, gen_ai.tool.name) | No sink at all: permdock/otel nests permdock.decide under the framework's execute_tool span, so the decision arrives with the trace (OpenTelemetry adapter). The GenAI conventions are still at Development status; the two attribute names the adapter copies are pinned and revisited on stabilisation |
| SIEM and security lake | Splunk, Microsoft Sentinel, Datadog Cloud SIEM, AWS Security Lake, Google SecOps, Elastic Security | OCSF, natively or through a mapping | A sink whose write applies the OCSF projection below and posts the batch; the projection is what makes one recipe serve all of them |
| Compliance evidence | Vanta, Drata, Secureframe | Access-review evidence from connected systems, usually pulled from a queryable log | Query your own sink's table (or the Cloud export) for events by subject.principal, actor and outcome over the review window; the event already carries the fields an access review asks for |
Two things make those recipes interchangeable:
- An OCSF projection. The Open Cybersecurity Schema Framework is the vendor-neutral schema SIEMs ingest. PermDock documents one mapping from a decision event onto the OCSF Authorization activity class (category System Activity or IAM, depending on the OCSF version in use):
outcometostatusandstatus_detail,permissionandscopeto theresourceandprivilegesfields,subject.principaltoactor.user,subject.actortoactor.processoractor.app_name,denials[].reasontostatus_detail,adapterandsourcetometadata.product. The projection is a pure function shipped astoOcsf(event)in a later@permdock/testingor CLI utility and documented on wire formats; a sink that wants SIEM-ready output applies it beforewrite. The event format itself does not change. - A CloudEvents envelope. When events leave the process over HTTP or a queue, wrap each in a CloudEvents 1.0 envelope:
typedev.permdock.decisionordev.permdock.approval,sourcethe service,subjectthe permission key,datathe event. Every broker and function platform accepts it, and the PermDock Cloud sink uses the same envelope on the wire.
Both are documented now, before the Cloud repository fixes its ingest format, because changing them later is a format bump for two repositories. Neither is a dependency; the projection and the envelope are plain objects.
What is not logged
The event never includes the resource object itself, the parsed schema output, the closure source, or any token or secret from authInfo. Add fields in your handler if you need them; resource.id is there so you can join.
permdock/otel
import { withOtel } from 'permdock/otel'
const permdock = withOtel(await createPermDock(policy, user), { tracer, meter })permdock/otel is an optional entry that subscribes to on('decision') and emits:
| Signal | Name | Attributes |
|---|---|---|
| Span (one per check) | permdock.check | permdock.permission, permdock.outcome, permdock.resource.type, permdock.subject.id, permdock.actor.id, permdock.actor.kind, permdock.adapter, permdock.denials (comma-joined reasons on denied) |
| Counter | permdock.checks | permdock.permission, permdock.outcome, permdock.adapter |
| Histogram | permdock.check.duration | same as the counter |
@opentelemetry/api is an optional peer of permdock/otel, never of core: @zap-studio/permit made it a required peer, which forces the dependency on every consumer; PermDock keeps core at zero runtime dependencies. The meter and tracer are resolved when withOtel is called so that module initialisation order relative to the SDK does not matter. The span is a child of whatever is active, so a check inside a Hono handler nests under the HTTP server span and a check inside an MCP tool nests under the tool call.
Problem Details
HTTP adapters answer a denied assert with 403 application/problem+json per RFC 9457. The body is generated from the Decision:
{
"type": "https://permdock.dev/problems/denied",
"title": "Permission denied",
"status": 403,
"detail": "post.delete was denied for the current subject.",
"instance": "/posts/42",
"permission": "post.delete",
"scope": "post:delete",
"resource": { "type": "post", "id": "42" },
"denials": [
{ "role": "member", "reason": "condition" }
],
"alternatives": ["post.read", "post.update"]
}{
"type": "https://permdock.dev/problems/approval-required",
"title": "Approval required",
"status": 403,
"detail": "post.delete requires human approval.",
"permission": "post.delete",
"scope": "post:delete",
"resource": { "type": "post", "id": "42" },
"reason": "human",
"token": "pd1.…"
}{
"type": "https://permdock.dev/problems/validation",
"title": "Invalid resource data",
"status": 400,
"permission": "post.update",
"resource": { "type": "post" },
"issues": [{ "path": ["authorId"], "message": "Expected string, received number" }]
}Rules the adapters follow:
typeis a stable URI per outcome (the base URI above is a placeholder until the docs domain is final).titleis constant pertype;detailis human-readable and may vary.- The extension members
permission,scope,resource,denials,alternatives,reason,tokenandissuesare the machine-readable part. Their names match theDecisionand the audit event, so a client library can parse all three with one type. approval-requiredis a 403 with its owntype, not a 401 and not a 202: the request was understood and refused pending a human. The client retries with aPermDock-Approval: <token>header once the request is approved in the store (approvals).- The body never contains the policy, the closure source, or another subject's data.
denialsnames roles the subject holds, which the subject already knows. - OpenAPI emission documents the 403 responses with these schemas on every protected operation.
Model-readable text: the detail strings are written for an LLM as much as for a person ("post.delete was denied for the current subject; permitted alternatives: post.read, post.update"), because the MCP and AI SDK adapters reuse them as refusal text. See errors.
Putting it together
| Question | Where the answer is |
|---|---|
| Was this specific request denied and why? | The Problem Details body, or the decision on the thrown PermDockDeniedError |
| Which agent did what for which user last week? | Your DecisionSink (or the PermDock Cloud decision log), filtered on subject.actor |
| Who approved that deletion, and when? | The approval events sharing the decision's token, with resolvedBy on the request record |
How often is post.delete denied per adapter? | The permdock.checks counter |
| Why is this page slow? | permdock.check spans nested in the request trace; a pending status on the client points at closure grants hitting the endpoint |
| What changed in who can do what? | permdock usage and the policy matrix snapshot in @permdock/testing, in review |
Open questions
- The Problem Details base URI.
- The
permdock/otelentry point shape (withOtelabove is a placeholder) and theon('error')event for failing handlers; the plan only fixes "span per check". - Whether
on('decision')should also fire for client-side snapshot evaluations (useful for UX analytics, noisy for audit); the current design emits only on the server and on the endpoint. - Whether
permdock/otelshould emit a log record per denial in addition to the span, for backends that index logs but not spans. - Sampling: whether
grantedevents should be sampled by default under high load whiledeniedandapproval-requiredare always emitted.
Validation
PermDock validates resource data against its Standard Schema only where it crossed a trust boundary, synchronously, with a typed error.
Extension interfaces
The fixed set of interfaces through which providers, stores, sinks and compilers plug into PermDock (SubjectResolver, MembershipSource, RoleSource, ApprovalStore, DecisionSink, SnapshotSource, LimitStore, WhereCompiler, on() events), their trust classes, the in-process default each ships with, how provider principal types are extended without global augmentation, and the conformance runners in @permdock/testing.