PermDock
Concepts

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 it

Implementations that ship or are documented:

SinkWhereWhat it does
memorySink()coreRing buffer; the default; what @permdock/testing asserts against
permdock/oteloptional entrySpans and metrics from the same events (below)
permdock/cloudoptional entryBatched delivery to the PermDock Cloud decision log with retention and per-actor queries (Cloud adapter)
Your owna few lineswrite 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:

DestinationExamplesIngestsRecipe
LLM observabilityLangfuse, LangSmith, Braintrust, Datadog LLM Observability, Sentry AI monitoring, PostHog LLM analyticsOpenTelemetry 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 lakeSplunk, Microsoft Sentinel, Datadog Cloud SIEM, AWS Security Lake, Google SecOps, Elastic SecurityOCSF, natively or through a mappingA sink whose write applies the OCSF projection below and posts the batch; the projection is what makes one recipe serve all of them
Compliance evidenceVanta, Drata, SecureframeAccess-review evidence from connected systems, usually pulled from a queryable logQuery 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): outcome to status and status_detail, permission and scope to the resource and privileges fields, subject.principal to actor.user, subject.actor to actor.process or actor.app_name, denials[].reason to status_detail, adapter and source to metadata.product. The projection is a pure function shipped as toOcsf(event) in a later @permdock/testing or CLI utility and documented on wire formats; a sink that wants SIEM-ready output applies it before write. 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: type dev.permdock.decision or dev.permdock.approval, source the service, subject the permission key, data the 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:

SignalNameAttributes
Span (one per check)permdock.checkpermdock.permission, permdock.outcome, permdock.resource.type, permdock.subject.id, permdock.actor.id, permdock.actor.kind, permdock.adapter, permdock.denials (comma-joined reasons on denied)
Counterpermdock.checkspermdock.permission, permdock.outcome, permdock.adapter
Histogrampermdock.check.durationsame 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:

  • type is a stable URI per outcome (the base URI above is a placeholder until the docs domain is final). title is constant per type; detail is human-readable and may vary.
  • The extension members permission, scope, resource, denials, alternatives, reason, token and issues are the machine-readable part. Their names match the Decision and the audit event, so a client library can parse all three with one type.
  • approval-required is a 403 with its own type, not a 401 and not a 202: the request was understood and refused pending a human. The client retries with a PermDock-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. denials names 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

QuestionWhere 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/otel entry point shape (withOtel above is a placeholder) and the on('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/otel should emit a log record per denial in addition to the span, for backends that index logs but not spans.
  • Sampling: whether granted events should be sampled by default under high load while denied and approval-required are always emitted.

On this page