Every authorization decision emits one structured log line on the apparitor logger.
This document freezes the schema from 0.1.0 as a stability contract. A failure in
tests/unit/test_log_contract.py is a breaking log-schema change; see the stability
policy below. For a runnable walk-through that configures the logger and emits, parses, and
verifies every contract line below, see examples/observability/.
A single logging.getLogger("apparitor") is shared across all modules (engine.py,
mapping.py, fastmcp.py, a2a.py). Levels used:
| Level | When |
|---|---|
INFO |
Contract lines (C1, C2, C3); adapter startup lines |
WARNING |
Operator-actionable conditions (mapping failure, PDP error, cache bypass, collapse guard) |
ERROR / exception |
Internal faults, adapter errors |
Sensitivity: subject ids are the decision principals and may be email addresses or
other user identifiers. Route the apparitor logger to a restricted sink accordingly.
Emitted by _log in engine.py at INFO level for every authorization call that
produces a decision (skip paths are silent; see below).
Format string:
apparitor decision verdict=%s status=%s subjects=%s correlation=%s resources=%s fingerprints=%s latency_ms=%.1f
Rendered example:
apparitor decision verdict=allow status=success subjects=['alice@acme.com', 'travel-bot'] correlation=corr-42 resources=['files/read'] fingerprints=['3a9f1c20e741'] latency_ms=12.3
Field table:
| Field | Type | Semantics | Can be None? |
|---|---|---|---|
verdict |
str enum |
Authorization outcome: allow, block, human_review |
No |
status |
str enum |
Evaluation path: success, error |
No |
subjects |
Python-repr list of strings | Sorted, deduplicated subject ids only (no type); one entry per distinct principal | No |
correlation |
str or None |
Value of correlation_id from the request context, rendered as the literal None when absent, never omitted |
Yes (renders as None) |
resources |
Python-repr list of strings | Resource ids, positional per evaluation request; dual-principal = two entries (one per leg), duplicates expected | No |
fingerprints |
Python-repr list of strings | 12-character hex digests, positional per request (same order as resources) |
No |
latency_ms |
float, always one decimal (%.1f) |
Wall-clock time from first perf_counter to emit, in milliseconds |
No |
Notes:
subjects=is the Python repr of a sorted, deduplicated list of subject id strings. Under dual-principal evaluation this names both the user and the agent. The list is always sorted so parsers can rely on stable ordering.correlation=renders the literal stringNone(not omitted, not JSONnull) when nocorrelation_idis present in the request context.resources=andfingerprints=carry one entry perEvaluationRequestsent to the PDP. A dual-principal call emits two entries even when the resource id is identical for both legs.- C1 is not emitted on SKIP paths (empty tool-call list, mapper abstention on all
calls). Silence is by design on skip; there is nothing to audit. SKIP paths emit no C1
line; in practice verdict is
allow|block|human_reviewand status issuccess|error.
Emitted in _evaluate_batch in engine.py at INFO level. Companion to C1 on
multi-request batches (the aggregate enforcement path, /evaluations) where the aggregate
verdict is not allow. The per-item evaluate_each path also uses a batch PDP call but
emits C3, never C2. C2 belongs to the aggregate enforcement path only.
Format string:
apparitor batch denied_legs=%s
Rendered example:
apparitor batch denied_legs=['user:alice@acme.com tool_call.execute files/delete', 'agent:travel-bot tool_call.execute files/delete']
Field table:
| Field | Type | Semantics | Can be None? |
|---|---|---|---|
denied_legs |
Python-repr list of strings | One entry per denied leg; each entry: <subject.type>:<subject.id> <action.name> <resource.id> |
No (empty list not emitted; a non-ALLOW aggregate from aggregate() implies at least one decision was false, so denied_legs is never empty when C2 fires) |
Notes:
- C2 carries
type:idin each entry (asymmetry with C1subjects=, which carries id only). This lets an operator distinguish a user-grant deny from an agent-boundary deny at a glance. - C2 never carries raw arguments or request context.
- C2 only fires on multi-request batches (
/evaluations); single-request denies produce C1 only.
Emitted in _emit_each in engine.py at INFO level. Advisory summary for
evaluate_each (per-item visibility filtering, e.g. tools/list). Not an audit
record: no subjects, resources, fingerprints, or correlation id.
Format string:
apparitor per-item decisions verdicts=%s latency_ms=%.1f
Rendered example:
apparitor per-item decisions verdicts=['allow', 'block'] latency_ms=8.4
Field table:
| Field | Type | Semantics | Can be None? |
|---|---|---|---|
verdicts |
Python-repr list of strings | One verdict string per call, positional | No |
latency_ms |
float, always one decimal (%.1f) |
Shared batch latency in milliseconds | No |
Notes:
- C3 is advisory only. Do not rely on it for audit, alerting, or access-control decisions; use C1 for enforcement-path records.
- Verdicts in
evaluate_eachcounters are indistinguishable from C1 enforcement decisions in the metrics sink; account for that when alerting on block rates.
The contract lines deliberately carry no timestamp token: the logging.LogRecord's
created attribute (rendered via your formatter's %(asctime)s) is the time source, as
is standard for Python logging. Two consequences for deployments that keep these lines as
audit records.
- Configure the formatter on the
apparitorlogger's handler to emit an ISO-8601 UTC timestamp (e.g.%(asctime)swith a UTC converter). A sink that stores onlygetMessage()without record metadata has no event time and cannot serve as an audit trail. - The host's clock is the clock of record. Run NTP-disciplined clocks on enforcement points in regulated deployments.
- List-valued fields (
subjects=,resources=,fingerprints=,verdicts=,denied_legs=) are Pythonreproutput using single quotes, not JSON. Parse them withast.literal_evalrather than a JSON parser. - Anchor on the prefixes
apparitor decision,apparitor batch, andapparitor per-itemto identify contract lines; the suffix fields follow in order. - Treat the rendered line as the interface. The log record
msg/argsstructure is internal;LogRecord.getMessage()(the formatted string) is the stable surface. latency_ms=always has exactly one decimal place (\d+\.\d).fingerprints=entries match[0-9a-f]{12}.
Each fingerprint is the first 12 hex characters of the SHA-256 over the canonical JSON
(json.dumps(..., sort_keys=True, separators=(",", ":"))) of the full EvaluationRequest
tuple (subject + action + resource including arguments + context). This is the same digest
as the ALLOW cache key.
What the fingerprint covers depends on redact_arguments:
- With
redact_arguments=False(non-default): argument values are hashed in full, so the fingerprint uniquely identifies a specific call including its argument values. - With
redact_arguments=True(default): argument values are replaced with"***redacted***"before hashing, so the fingerprint covers the tool/resource/subject/ action/context plus argument key names only. Two calls differing only in argument values will produce the same fingerprint.
This is a contract fact: do not rely on fingerprints to distinguish calls that differ only
in argument values unless redact_arguments=False is explicitly configured.
- Raw tool arguments. Arguments are fingerprinted; the digest identifies a call without exposing possibly-sensitive argument values.
- Tokens and credentials. No bearer tokens, API keys, or session material ever appear in any log line.
- Engine reasons toward the wire. Refusals exposed to callers are generic
(
"blocked by authorization policy"); the rich reason stays in the operator log. - Subject types. C1
subjects=carries ids only. Type information appears in C2denied_legs=entries.
Treat the apparitor logger as sensitive. Subject ids are the decision principals and
may be email addresses or other user identifiers. Route this logger to a restricted,
access-controlled sink and do not forward it to end users.
Informational lines use the apparitor: <message> form (colon after apparitor); contract
lines use apparitor decision|batch|per-item (no colon). That delimiter difference is the
operator's separator. Informational lines are not part of the stability contract and
may change without notice.
| Prefix | Level | When |
|---|---|---|
apparitor: metrics/log emission failed (verdict unaffected) |
ERROR | MetricsSink or logging raised; decision already returned |
apparitor: cache metric emission failed (verdict unaffected) |
ERROR | record_cache raised inside the decision path |
apparitor: mapping failed, blocking (%s) |
WARNING | AuthZENConfigError from mapper on aggregate path |
apparitor: mapping failed, blocking all items (%s) |
WARNING | AuthZENConfigError from mapper on per-item path |
apparitor: PDP error, resolved as %s |
WARNING | AuthZENServiceError; verdict resolved via on_error |
apparitor: unexpected internal error during evaluation |
ERROR | Unhandled exception in evaluation; blocked fail-closed |
apparitor: dual-principal evaluation always batches, … |
WARNING | cache_enabled=True with a dual-principal path |
apparitor: boundary collapse guard refused: %s |
WARNING | Resolved caller equals boundary subject |
apparitor: FastMCP middleware gating … |
INFO | Middleware startup |
apparitor: A2A executor gating agent.invoke for … |
INFO | Executor startup |
apparitor: FastMCP authorization middleware error (refusing) |
ERROR | Unhandled exception in FastMCP middleware |
apparitor: listing filter error (hiding all tools) |
ERROR | Unhandled exception during tools/list filtering |
apparitor: A2A authorization executor error (refusing) |
ERROR | Unhandled exception in A2A executor |
apparitor: refusal metric emission failed (verdict unaffected) |
ERROR | Metrics raised on the refusal path |
apparitor: authenticated A2A user has no user_name; refusing |
WARNING | A2A peer missing identity |
apparitor: no usable server label for prompt authorization; refusing |
WARNING | FastMCP missing server label for prompt |
apparitor: unusable prompt name for authorization; refusing |
WARNING | FastMCP bad prompt name |
apparitor: unusable skill id from skill_resolver; refusing |
WARNING | A2A bad skill id |
apparitor: access token has no usable <claim> claim; refusing (…allow_workload_subject…) |
WARNING | FastMCP: verified token has no usable sub (or configured claim) and allow_workload_subject not set; call refused |
apparitor: no authenticated subject for MCP request; refusing (…) |
WARNING | FastMCP: no token, no injected subject, and allow_static_subject not set; call refused |
apparitor: no authenticated subject for A2A invocation; refusing (…) |
WARNING | A2A: no authenticated peer identity, no injected subject, and allow_static_subject not set; call refused |
The schema is designed so the operator's sink can satisfy the EU obligations that most
commonly attach to agent authorization records. apparitor provides the recording
capability; retention, residency, and access control are properties of the sink you
route the apparitor logger to. Confirm applicability with your compliance function. This
section maps the schema, it is not legal advice.
| Obligation | How the schema relates |
|---|---|
| AI Act (Reg. (EU) 2024/1689) Art. 12, record-keeping. High-risk AI systems must technically allow automatic recording of events enabling traceability of the system's functioning. | C1 is that record for the authorization function: per-decision outcome, every principal involved, the resource acted on, and a per-call fingerprint. Set correlation_id on every request so decisions chain to sessions/tasks. For regulated deployments treat it as required, not optional. |
| AI Act Arts. 19 / 26(6), log retention. Providers and deployers of high-risk systems keep automatically generated logs at least six months (longer where other law requires). | Retention happens at the sink, not in apparitor (persistence is deliberately out of scope pre-v0.1). Route the logger to a sink with a retention policy meeting your role's obligation. |
| AI Act Art. 14, human oversight. | verdict=human_review records that a decision was escalated to a human. Who reviewed it and the outcome happen outside apparitor. Your review workflow must produce its own record and can join on correlation / fingerprints. |
| GDPR (Reg. (EU) 2016/679), personal data in logs. | subjects= ids and denied_legs= entries are personal data when they identify people (emails). You need a lawful basis (security/audit logging is commonly Art. 6(1)(f)); minimization is designed in (no raw arguments, no tokens, generic wire reasons); prefer pseudonymous subject ids from your IdP where policy allows. Fingerprints are pseudonymized, not anonymous: the digest is linkable to the request tuple by anyone who can reconstruct it. |
| GDPR Arts. 5(1)(e), 17, storage limitation and erasure. | Audit retention and erasure requests are in tension; Art. 17(3)(b)/(e) exemptions (legal obligation, legal claims) typically cover security audit trails for their retention window. Pseudonymous subject ids make this materially easier. Decide before go-live, not at the first request. |
| GDPR Ch. V / data sovereignty. | Routing the apparitor logger to a sink outside the EU/EEA is a personal-data transfer. If residency is a requirement, the log pipeline (not just the PDP) must stay in-region. |
| NIS2 (Dir. (EU) 2022/2555) / DORA (Reg. (EU) 2022/2554). | The decision log feeds detection and incident reconstruction (denied legs name which principal was stopped, fail-closed errors are visible at WARNING/ERROR). Integrity protection (append-only storage, tamper evidence) is a sink property; these lines carry no signatures. |
One known limitation to weigh for AI Act traceability: with the default
redact_arguments=True, the input data of a call is represented only by argument key
names inside the fingerprint (see above). Some obligations require reconstructing what
was attempted, not just that it was attempted, by whom, on which resource. Those
deployments must either set redact_arguments=False (weigh the GDPR minimization cost) or
keep input records in an adjacent system joined via correlation.
From 0.1.0 the contract lines C1, C2, and C3 are append-only: new key=value
tokens may be added at the end of a line in a future release. Renames, removals, and
semantic changes to existing tokens are breaking and require:
- A
CHANGELOG.mdentry under Changed with the text "Update log parsers". - A version bump per semantic versioning.
tests/unit/test_log_contract.py pins the documented grammar; a failure there means a
breaking change is in flight.
Out of scope (tracked, deferred): structured log persistence, cross-session
aggregation, retention, and compliance export are post-v0.1 work items. See
ROADMAP.md.