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, explain and filter emits one event per evaluated check (filter emits one event with the counts, not one per row; simulate emits one simulate event carrying the worst decision of the batch and the counts). The event is a frozen JSON object carrying outcome, permission, scope, resource, the subject (principal, actor, delegation), the active tenant, the full membership that supplied the matched role, via, matched, denials, alternatives, token, trusted, source and adapter, never the explain trace; the full shape and an example are on wire formats. Events are emitted on the server and on the decision endpoint, never for client-side snapshot evaluations, so the audit log records only authoritative decisions.
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.
matched.name and matched.meta carry the grant's declared name and app data, and a granted event carries its obligations, so a sink can group decisions by the rule that allowed them or by an app tag such as a ticket id (extend PermDock). The OCSF projection puts the grant name and the app obligation names under unmapped, and the OTel span adds permdock.matched.name and permdock.obligations.
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) => {
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:
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, signer }); // ring buffer; `signer` signs each write as `permdock-decisions+jwt`
createPermDock(policy, { subject, sink }); // every request-scoped instance writes to itImplementations that ship or are documented:
| Sink | Where | What it does |
|---|---|---|
memorySink({ signer }) | core | Ring buffer; the default; optional TokenSigner signs each write (typ: permdock-decisions+jwt); 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 sink passed to scimHandler also receives directory events (type: 'directory', source: 'scim': the provisioning operation, tenant, resource type and id, and the credential kind), which join to later decisions on via: 'group:<id>'. Group membership changes emit a membership event per affected user next to the directory event. membershipEvent() and onRoleChange({ sink }) write the same shape for in-app role changes. API keys add credential events (created, rotated and revoked from credentialEvent() or memoryCredentials({ sink }), and used from subjectFromApiKey({ sink, sample }), always marked with the sample fraction it reports), and every decision a key makes names it in subject.credential (API keys). A failing sink is reported to on('error') and never changes an outcome.
Every event is written by default. A sink receives all granted, denied and approval-required events. Sampling is opt-in per sink (sample: { granted: 0.1 } keeps one routine grant in ten and always writes the other two outcomes) and is meant for a metrics or debugging sink, never for the sink that serves as evidence: an access review asks what a principal or an agent was allowed to do, so a log that drops grants cannot answer it. Cost is controlled by retention, not by omission.
Signed batch export. A sink that hands events to a party who must be able to prove where they came from (a SIEM in another trust domain, an auditor, a compliance tool reading an export file) can sign each batch. The batch becomes one compact JWS with typ: permdock-decisions+jwt, registered claims iss, iat, exp, jti, and the CloudEvents-enveloped events under the events claim (wire formats, JOSE); the consumer verifies it with any JOSE library against the signer's JWK Set. In this package it is a TokenSigner handed to a sink, not a new sink: memorySink({ signer, audience }) signs each write, signDecisionBatch(events, signer) is the same helper for your own sink, and permdock/cloud's export endpoint serves signed batches. The recipe is signer.sign({ events }, { typ: 'permdock-decisions+jwt' }) before write posts the batch. Events inside the JWS are the same objects a plain sink receives, so a consumer verifies, unwraps and applies the OCSF projection below unchanged. Signing is evidence for the reader; nothing in PermDock reads a batch back.
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 (adapters, 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 ships one mapping from a decision event onto the OCSF 1.3.0 Authorize Session class:
outcometostatus_idandstatus,denials[].reasontostatus_detail,permissiontoprivileges,subject.principaltouserandactor.user,subject.actortoactor.app_name,adaptertometadata.product.feature, and the fields OCSF has no slot for underunmapped. The projection is the pure functiontoOcsf(event)frompermdock, specified on wire formats; a sink that wants SIEM-ready output applies it beforewrite, and the Cloud's SIEM connector runs the same function. A break-glass decision is raised to high severity withbreakGlass,purposeandreasonunderunmapped, andaccessToOcsf(event)projects a support-access lifecycle event onto the OCSF Account Change class (elevated access). 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.
The PermDock Cloud ingest reads the same envelope, so both are part of the wire contract. 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.
Evidence and governance
"Audit" names the mechanism above. What the log is for, and what PermDock Cloud sells it as, is compliance evidence and agent governance: the decision event already carries the fields an access review or an agent-governance review asks for (subject.principal, subject.actor, delegation, tenant, permission, outcome, matched, via), so the work is queries and exports, not new data. The list below is the contract for any sink table and for the Cloud UI; a self-hoster's Postgres sink and the Cloud answer the same questions.
Questions the log must answer
| Review | Query over the events | Fields |
|---|---|---|
| Access review: what could this person do, and what did they do, in this window | subject.principal.id and tenant over a time range, grouped by permission and outcome; joined with the roles and memberships in matched and membership | principal, tenant, permission, outcome, matched.role, via |
| Agent activity: what did this agent do, for whom, under which authority | subject.actor.id over a time range, grouped by subject.principal.id and permission; delegation.scopes and authorizationDetails for the authority it used | actor, principal, delegation, permission, outcome |
| Approval chain: who approved this action and what exactly was approved | The approval events (phase: requested, phase: resolved) and the resumed decision event sharing token; resolvedBy on the request record. Session revocation calls cancelApprovals and records resolvedBy: 'system:ssf' | token, resolvedBy, permission, resource.id |
| Denials and drift: what is being refused, and did a policy change move outcomes | outcome: denied grouped by denials[].reason and permission; the catalog version in adapter metadata before and after a publish | denials, permission, adapter |
| Tenant scope: everything that happened inside one customer's tenant | Filter on tenant; membership and via explain each grant | tenant, membership, via |
| Provisioning: who gave this person that role | The membership events from SCIM, Better Auth, Clerk or membershipEvent() (roles added or removed, optional by) joined to later decisions on via | membership event, via |
| Machine access: which API keys exist, who made them, and what they did | credential events (created, rotated, revoked, sampled used) joined to decisions on subject.credential.id | credential event, subject.credential |
Exports
Evidence leaves the log in three forms, all documented here so a reader in another trust domain can verify and ingest without PermDock code:
- Signed batches (
permdock-decisions+jwt, above): provenance for an auditor or a compliance tool; the Cloud's export endpoint serves them and a self-hosted sink can sign its own. - OCSF: the projection below for SIEMs and security lakes.
- CSV with one row per event, for compliance platforms (Vanta, Drata, Secureframe) that collect access-review evidence from connected systems and for spreadsheets in an audit. The columns are fixed, in this order:
time(the event'sat, RFC 3339),principal(subject.principal.id, empty for anonymous),actor(subject.actor.id),tenant,permission(the key),outcome,matched.role,via,denials.reason(the reasons joined with;),token. A list inside a cell never uses commas, so the file parses with any RFC 4180 reader.toCsvRow(event)frompermdockwrites one row (a field is quoted only when it contains a comma, a double quote, CR or LF) andCSV_COLUMNSis the header; rows are joined with CRLF. The Cloud export and a self-hosted sink use the same helper, so their files are identical. Columns beyond these belong in the signed batch, not in the CSV.
Access-review template
An access review asks a named owner to confirm, per person, that what they could do and what they did is still right. Run it per tenant and per review window (a quarter is common) from the sink or the Cloud export:
- Scope. The tenant, the window, the reviewer, and the permissions in scope (usually every permission the catalog marks destructive or
approval: 'human', plus anythinghostable). - Population. Every principal with a
grantedorapproval-requiredevent on an in-scope permission in the window, from the CSV filtered ontenantandpermission. Add principals whose current memberships grant an in-scope permission but who did not use it; unused access is the finding reviews most often surface. - Per principal. The roles and memberships behind each grant (
matched.role,via), the count ofgranted,deniedandapproval-requiredevents per permission, and the agents that acted for them (actor). - Decision. The reviewer marks each principal and permission keep, reduce or revoke, with a reason. Reduce and revoke change the role or membership at its source (the identity provider, SCIM, the application's
RoleSource); PermDock records the effect on the next decisions and never applies the change itself. - Evidence. The CSV rows, the signed batch covering the same window, and the reviewer's decisions, filed with the compliance platform.
Agents get the same review keyed on actor instead of principal: which delegations they used, for whom, and whether any approval-required call was approved by the principal it acted for.
Retention is the cost lever: the Cloud offers retention tiers; a self-hosted sink keeps what its table keeps. Sampling is never the lever (see above).
Governance actions, and what they are not
Governance means acting on what the log shows. The actions PermDock Cloud (or an application over its own sink) may take are exactly two, and neither is a decision:
- Reject pending approvals for an actor, a principal or a tenant through
ApprovalStore.resolve(token, 'rejected'). This stops in-flight approval-gated actions. - Invalidate snapshots through
SnapshotSource, so cached clients refetch and a revoked membership or role takes effect at the edge.
A "freeze this agent" button in the Cloud is those two operations plus a recommendation: the application's own RoleSource or context reads a kill-switch table the application owns, so the next decide denies. The Cloud cannot deny a future check, because nothing the Cloud stores is consulted on the decision path (invariant 15). This is the same reason the log is trustworthy as evidence: it records decisions it could not have influenced.
OpenTelemetry is not the evidence path
permdock/otel (below) emits spans and metrics from the same events, and an OTLP export from the Cloud does the same for backends that want a copy next to their traces. Both are observability: collectors sample, tail-sample and drop; retention is short; a span is not signed. The evidence is the sink's log and its signed export. Do not point an auditor at a tracing backend, and do not send OTel spans to a DecisionSink (Cloud adapter).
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 the status protect sends for the same decision (403, or 401, 404, 429 or 503) as application/problem+json per RFC 9457, generated from the Decision. The denied, approval-required and validation bodies are on wire formats.
Rules the adapters follow:
typeis a stable URI per outcome under the fixed basehttps://permdock.com/problems.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, what a closure threw, 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 |
| What could this person do in this tenant during the review window, and did they? | The access-review query above over your sink or the Cloud export; signed batch or CSV for the compliance platform |
| Who approved that deletion, and when? | The approval events sharing the decision's token, with resolvedBy on the request record |
| Who gave this person the editor role? | The membership event whose roles.added includes editor, filtered by tenant, joined on via when the change came from a group |
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 |
Last updated on
Validation
PermDock validates resource data against its Standard Schema unless the caller marks it as a row the server loaded, synchronously, with a typed error.
Extension interfaces
The fixed set of interfaces through which providers, verifiers, signers, stores, sinks and compilers plug into PermDock (SubjectResolver, TokenVerifier, TokenSigner, MembershipSource, RoleSource, ApprovalPolicySource, DirectoryStore, ApprovalStore, DecisionSink, SnapshotSource, LimitStore, ReplayStore, RevocationFeed, 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.