{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://actum.run/spec/audit-event.schema.json",
  "title": "Audit Event",
  "description": "One normative, append-only audit event — the single event shape behind both audit transports: the retained, queryable chain (pull, GET /v1/audit/{chain}) and the always-on JSON-lines log exhaust (push, see log-standard.md). Events belong to a chain ('server' plus one per namespace) and carry seq + prev_hash so a verifier can prove no interior record was altered or dropped; signed checkpoints (see $defs/checkpoint) prove the head against tail-truncation. REDACTION IS NORMATIVE: every field is classified into one of three redaction classes so exhaust is safe to ship to a SIEM by construction, not by deployment review — (1) NEVER: secrets, credential material, Authorization headers — structurally absent from every event and every transport (vault-injected at send time, never in template state), so they simply do not appear as fields here; (2) DIGEST-ONLY: request/response bodies and resolved template values — carried as a digest (see execution.schema.json#/$defs/digest), never the value; (3) CLEARTEXT: identifiers, actors, actions, states, timestamps, resource names. One structural class also exists: (4) COMPOSITE: an object (e.g. resolved_step) whose own sub-fields carry the true per-field classes — a router MUST descend and apply the sub-field annotations, never a single class to the whole object. Each property below is tagged with a 'redaction' annotation naming its class. See docs/regulated-audit-requirements.md §6.3-6.4.",
  "type": "object",
  "required": [
    "spec_version",
    "chain",
    "seq",
    "ts",
    "actor",
    "action",
    "target",
    "outcome"
  ],
  "properties": {
    "spec_version": {
      "type": "string",
      "redaction": "cleartext",
      "description": "The spec version this event conforms to."
    },
    "chain": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9_-]{0,31}$",
      "redaction": "cleartext",
      "description": "The chain this event belongs to: the reserved 'server' chain (principals, server config, cross-cutting admin) or a namespace name (all activity scoped to that namespace). Per-namespace scope keeps seq gap-detectable without serializing namespaces and stops a dev-only verifier from reading prod activity.",
      "examples": ["server", "prod"]
    },
    "seq": {
      "type": "integer",
      "minimum": 0,
      "redaction": "cleartext",
      "description": "Monotonic, gap-detectable sequence number within the chain (starts at 0). A gap proves a missing record."
    },
    "prev_hash": {
      "type": "string",
      "pattern": "^[0-9a-f]{64}$",
      "redaction": "cleartext",
      "description": "Lowercase hex SHA-256 of the JCS canonical encoding (RFC 8785) of the previous event (seq-1) in this chain, over the event JSON excluding this prev_hash-derived linkage's own transient fields. Naming the canonicalization is normative: JSON has no inherent canonical form, so without a fixed scheme two implementations produce different hashes and cross-implementation chain verification is impossible. Required on every event except the genesis event (seq 0); see the top-level if/then that enforces it. prev_hash proves no interior record was altered or dropped; it cannot detect tail-truncation — that is what signed checkpoints are for."
    },
    "ts": {
      "type": "string",
      "format": "date-time",
      "redaction": "cleartext",
      "description": "Event time, UTC ISO-8601. When the namespace's profile enforces trusted_time, this timestamp is bound to a trusted time source."
    },
    "actor": {
      "type": ["string", "null"],
      "redaction": "cleartext",
      "description": "The principal that performed the action (id or name, never the API key). Always present (required): explicitly null in local single-trusted-caller mode, where there are no principals. Present-but-null is normative — an absent field would be indistinguishable from 'not recorded', which an attribution-checking verifier (21 CFR Part 11 §11.50 / NIST AU-9) must be able to rule out."
    },
    "action": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$",
      "redaction": "cleartext",
      "description": "The auditable action, as a dotted verb. Covers execution lifecycle and every administrative action (the HTTP/admin layer emits these too — closing the observability gap where only MCP tool calls were logged). Open string constrained only by the dotted-verb pattern above (no closed enum): the examples list is illustrative, not exhaustive, so new action names (e.g. from later governance stories) are additive and never require a schema change.",
      "examples": ["execution.step.attempt", "execution.create", "publication.create", "publication.decision", "publication.approve", "publication.yank", "namespace.profile.assign", "profile_change.approve", "policy.update", "connection.rotate", "principal.create", "execution.approval.approve", "execution.approval.reject", "execution.approval.timeout", "namespace.freeze", "namespace.unfreeze", "execution.suspend", "execution.resume", "execution.preflight", "execution.unwind.skip", "execution.unwind.override", "composition.version.create"]
    },
    "target": {
      "type": "object",
      "additionalProperties": false,
      "required": ["type"],
      "redaction": "cleartext",
      "description": "The resource the action was performed on.",
      "properties": {
        "type": {
          "type": "string",
          "description": "The resource type. Constrained to the core Actum resource types so SIEM alert rules and conformance queries key on a controlled vocabulary (an unconstrained string lets 'Execution' silently drop out of a rule matching 'execution'). A deployment MAY record additional implementation-specific types using an 'x-<vendor>.<name>' prefix, which can never collide with a future core type.",
          "anyOf": [
            { "enum": ["execution", "publication", "namespace", "profile", "composition", "policy", "connection", "principal"] },
            { "pattern": "^x-[a-z0-9]+\\.[a-z][a-z0-9_]*$" }
          ]
        },
        "id": {
          "type": "string",
          "description": "The resource id or semantic key (e.g. a publication id, or a composition name@version)."
        },
        "name": {
          "type": "string",
          "description": "A human-readable name for the resource, when distinct from id."
        }
      }
    },
    "outcome": {
      "type": "string",
      "enum": ["success", "failure", "denied"],
      "redaction": "cleartext",
      "description": "success (the action completed), failure (attempted but errored), or denied (refused by authorization or policy — the auditable 'who tried what and was stopped')."
    },
    "severity": {
      "type": "string",
      "enum": ["info", "warning", "error", "critical"],
      "default": "info",
      "redaction": "cleartext",
      "description": "Explicit severity for SIEM alerting (not left to be derived from outcome). Privilege changes and denials on gated namespaces warrant warning+; a failed audit write under fail_closed is critical."
    },
    "execution_id": {
      "type": "string",
      "redaction": "cleartext",
      "description": "Correlation: the execution this event belongs to, when the action is part of one. Joins an event to the per-execution projection at GET /v1/executions/{id}/events."
    },
    "step_id": {
      "type": "string",
      "redaction": "cleartext",
      "description": "Correlation: the step within the execution, for step-level events."
    },
    "request_id": {
      "type": "string",
      "redaction": "cleartext",
      "description": "Correlation: the per-request id (UUID4) that ties every event of one API/tool invocation together across instances, no shared state required."
    },
    "traceparent": {
      "type": "string",
      "redaction": "cleartext",
      "description": "Correlation: the W3C Trace Context traceparent, passed through verbatim when the caller supplied one, for distributed-trace joins."
    },
    "resolved_step": {
      "$ref": "https://actum.run/spec/execution.schema.json#/$defs/resolvedStep",
      "redaction": "composite",
      "description": "For step events: the resolved request (method, url, connection, headers/body DIGESTS, undo). The same shape execution.plan uses for dry-run. SIEM ROUTING RULE: this envelope is redaction-class 'composite' — a router MUST apply each sub-field's own redaction annotation (method/url/connection/undo are cleartext; headers_digest/body_digest are digest-only), NOT a single class to the whole object. It is safe to embed by construction: bodies and headers are digest-only, and secret-referencing URL query/path params are stripped (redaction-class never — see execution.schema.json#/$defs/resolvedStep.url), so it leaks no PII or secret."
    },
    "detail": {
      "type": "object",
      "redaction": "digest-only",
      "description": "Optional additional structured detail for the action. Any body or resolved template value placed here MUST be a digest (execution.schema.json#/$defs/digest), never the raw value; cleartext identifiers/states are permitted. Because both digests and cleartext identifiers are legitimate here, this MUST is a runtime contract the schema cannot enforce structurally (the object stays open); SIEM consumers should treat the field as redaction-class digest-only per its annotation. This object is where implementations add action-specific context without ever widening the redaction contract."
    },
    "actor_provenance": {
      "$ref": "https://actum.run/spec/execution.schema.json#/$defs/provenance",
      "redaction": "composite",
      "description": "Optional actor/delegation chain for this event, the same shape execution.schema.json's $defs/provenance uses on the execution document -- so an approval decision (execution.approval.approve/reject) and the execution events it relates to can carry an identical provenance chain, joinable without re-deriving it. Same trust annotation as the $def: only the authenticated principal (this event's actor) is server-attested; on_behalf_of/agent/model are caller-asserted regardless of what the record's attested array contains. Redaction-class composite: a router MUST descend and apply the sub-fields' own classes (the $def's fields are all cleartext identifiers), not a single class to the whole object."
    },
    "signature": {
      "$ref": "#/$defs/signatureEnvelope",
      "redaction": "cleartext",
      "description": "Present when the namespace's profile enforces signing: a non-repudiable signature over this event (or, on a checkpoint, over the chain head). See $defs/signatureEnvelope."
    }
  },
  "if": {
    "properties": { "seq": { "exclusiveMinimum": 0 } },
    "required": ["seq"]
  },
  "then": {
    "required": ["prev_hash"]
  },
  "$defs": {
    "signatureEnvelope": {
      "type": "object",
      "additionalProperties": false,
      "required": ["signer", "signed_at", "meaning", "binds", "algorithm", "signature"],
      "description": "One signature-envelope shape, four uses: a publication decision, a profile-change decision, a chain checkpoint, and a derived-view attestation (meaning 'evidence.pack' — spec/oscal-export.md §4.2). It answers who / when / what-it-means / what-it-binds-to (Part 11-style: signer, time, meaning, and the record binding), so a signature can never be silently re-pointed at different bytes. Referenced by publication.schema.json, namespace.schema.json ($defs/profileChange), and $defs/checkpoint below.",
      "properties": {
        "signer": {
          "type": "string",
          "description": "WHO: the identity of the signer — a principal ID (preferred; unambiguously identifies a person) or a cryptographic key fingerprint. Implementations enforcing 21 CFR Part 11 §11.50 or AU-10 non-repudiation MUST ensure this value maps unambiguously to a specific human signatory (a key fingerprint requires an external key-to-person registry)."
        },
        "signed_at": {
          "type": "string",
          "format": "date-time",
          "description": "WHEN, UTC ISO-8601. For the three event-signing uses (decisions, checkpoints): wall-clock signing time, trusted-time-bound when the profile enforces trusted_time (AU-8). For the derived-view attestation use (meaning 'evidence.pack'): the attested evidence window's `until` boundary — the as-of time of the attested content, NOT a wall-clock signing event, because the envelope is a deterministic, regenerable function of the audit trail (byte-identical on every regeneration, spec/oscal-export.md §4.1-§4.2) and no unique signing event exists to record; trusted_time binding applies to the audit records the pack cites, not to this derived view."
        },
        "meaning": {
          "type": "string",
          "description": "WHAT IT MEANS: the semantic act being attested, so a signature over bytes is not mistaken for a different intent.",
          "examples": ["publication.approve", "publication.reject", "profile_change.approve", "profile_change.reject", "audit.checkpoint", "execution.approval.approve", "execution.approval.reject", "evidence.pack"]
        },
        "reason": {
          "type": "string",
          "description": "The decider's note, surfaced at the signature. Optional and non-load-bearing for the signature's integrity: the reason is already inside the signed bytes (it is a field of the decided record whose JCS hash the binds.content_hash form commits to — see the approvalRecord/profileChange/publication content_hash patterns), so this field does not add anything to what is signed; it exists only so a reader does not have to fetch and re-derive the bound record's hash to see why a decision was made. When present it MUST equal the reason carried on the signed record."
        },
        "binds": {
          "type": "object",
          "additionalProperties": false,
          "description": "RECORD BINDING: exactly what these bytes are. A content_hash for a publication/profile-change/approval-record decision, or a chain head (chain + seq + head_hash) for a checkpoint. Exactly one binding form is present — the two are mutually exclusive (a decision binding may not carry checkpoint fields and vice-versa). PUBLICATION BINDING CORRECTION (D4, 0.8.0): a publication.signature's content_hash binds the JCS hash of the *decided publication record* (the whole record: state, decided_by, reason, decided_at, and the publication's own content_hash field, at decision time) — never the published composition's content_hash alone. This aligns publication with how profileChange and approvalRecord already compute their content_hash (whole-decided-record, not a single inner field), so reason/decision/decided_by are inside the signed bytes and a signature cannot be silently re-pointed at a different decision over the same artifact. This corrects 0.7.0's publication-signing behavior, which bound the bare composition content_hash, outside the signed bytes; called out as a 0.8.0 changelog-worthy correction (Part 11 signing shipped immediately before 0.8.0 with zero production deployments, so no existing signature is invalidated by the correction). The approval-record decision (approvalRecord.signature, meaning execution.approval.approve/reject) reuses this identical content_hash branch — the JCS hash of the decided approval record — documented here as another use of the same existing form; no new binds branch is introduced for it.",
          "properties": {
            "content_hash": {
              "type": "string",
              "pattern": "^[0-9a-f]{64}$",
              "description": "The content hash the decision binds to (publication / profile-change / approval-record): lowercase hex SHA-256 over the JCS canonical JSON (RFC 8785) of the whole record being decided, at decision time — for a publication, the decided publication record (which itself contains the published composition's content_hash field); for a profile-change or approval-record, the decided record itself. Never the bare composition content_hash on its own (see this binds object's publication-binding correction note above)."
            },
            "chain": {
              "type": "string",
              "description": "The chain a checkpoint binds to."
            },
            "seq": {
              "type": "integer",
              "minimum": 0,
              "description": "The head seq a checkpoint binds to."
            },
            "head_hash": {
              "type": "string",
              "pattern": "^[0-9a-f]{64}$",
              "description": "The hash of the head event a checkpoint binds to."
            }
          },
          "oneOf": [
            {
              "required": ["content_hash"],
              "not": { "anyOf": [ {"required": ["chain"]}, {"required": ["seq"]}, {"required": ["head_hash"]} ] }
            },
            {
              "required": ["chain", "seq", "head_hash"],
              "not": { "required": ["content_hash"] }
            }
          ]
        },
        "algorithm": {
          "type": "string",
          "enum": ["ed25519", "ecdsa-p256-sha256", "ecdsa-p384-sha384"],
          "description": "The signature algorithm identifier. Constrained to a set of currently-acceptable algorithms; deprecated/weak algorithms (md5, sha1, rsa-pkcs1-v1.5-sha1) are excluded by construction."
        },
        "signature": {
          "type": "string",
          "description": "The signature value, base64url-encoded (RFC 4648 §5, no padding). Naming the encoding is normative: standard base64 and base64url are not interchangeable, so an unspecified choice breaks cross-implementation verification."
        }
      }
    },
    "checkpoint": {
      "type": "object",
      "additionalProperties": false,
      "required": ["spec_version", "chain", "seq", "head_hash", "created_at"],
      "description": "A periodic signed checkpoint over a chain head (GET /v1/audit/{chain}/checkpoints). prev_hash proves interior integrity but not that the newest records were not deleted; a checkpoint (seq + head_hash, signed) lets a verifier prove the current head, defeating tail-truncation. This is the natural signing(C)/chain(B) join — the signing capability countersigns the chain.",
      "properties": {
        "spec_version": {
          "type": "string",
          "description": "The spec version this checkpoint conforms to."
        },
        "chain": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9_-]{0,31}$",
          "description": "The chain this checkpoint attests."
        },
        "seq": {
          "type": "integer",
          "minimum": 0,
          "description": "The head sequence number at checkpoint time."
        },
        "head_hash": {
          "type": "string",
          "pattern": "^[0-9a-f]{64}$",
          "description": "The hash of the head event (seq) — the value a verifier compares its own walk against."
        },
        "created_at": {
          "type": "string",
          "format": "date-time",
          "description": "When the checkpoint was cut."
        },
        "signature": {
          "$ref": "#/$defs/signatureEnvelope",
          "description": "The signature over this checkpoint (meaning 'audit.checkpoint', binds chain+seq+head_hash). Present when the profile enforces signing. NORMATIVE: an unsigned checkpoint provides no tamper-evidence beyond prev_hash — the head_hash could be fabricated by the same party truncating the tail. A verifier MUST NOT treat an unsigned checkpoint as a chain-integrity proof; it is structurally valid but a signed checkpoint is required for the tail-truncation guarantee."
        },
        "external_anchor": {
          "type": "object",
          "additionalProperties": false,
          "required": ["type", "value"],
          "description": "OPTIONAL: an external, third-party anchor for this checkpoint's head_hash — a stronger tail-truncation defense than an in-process signature alone, since it does not depend on trusting this server's own key custody. A checkpoint's signature proves this server attests the head; an external_anchor additionally proves a third party (an RFC 3161 timestamp authority, or a public transparency log) observed that exact head_hash at or before a given time, so even a compromised server signing key cannot retroactively fabricate an earlier, different head. Not populated by this reference implementation (no RFC 3161/transparency-log integration exists yet) — this is the normative slot reserved for it, additive and optional so no existing checkpoint is invalidated by its absence.",
          "properties": {
            "type": {
              "type": "string",
              "enum": ["rfc3161", "transparency_log"],
              "description": "The anchor mechanism: 'rfc3161' (RFC 3161 trusted timestamp over head_hash, from a Time-Stamping Authority) or 'transparency_log' (an entry in a public append-only log, e.g. Certificate-Transparency-style, committing to head_hash)."
            },
            "value": {
              "type": "string",
              "description": "The anchor's own proof material: for 'rfc3161', the base64-encoded DER TimeStampToken; for 'transparency_log', the log entry's inclusion proof / index reference, in a form that log's own verifier accepts. Opaque to this schema — interpreted only by a verifier that understands `type`."
            },
            "anchored_at": {
              "type": "string",
              "format": "date-time",
              "description": "When the external anchor was obtained, UTC ISO-8601 — distinct from `created_at` (when this server cut the checkpoint), since a real TSA/transparency-log round trip happens after the checkpoint is signed."
            }
          }
        }
      }
    }
  }
}
