Skip to content

ADR-0013: ACT/ECT as the primitives behind trails-identity and trails-prov

  • Status: Accepted
  • Date: 2026-04-12
  • Supersedes:
  • Superseded by:

Context

ADR-0010 adopted biscuit as the capability-token format, and ADR-0009 committed to always-on PROV-O provenance with a Trails-specific writer. Both decisions predate the author's IETF work on two drafts that target exactly this space:

  • ACT (Agent Capability Tokens, draft-nennemann-act-00) — JWT-based authorization mandates encoding what an agent MAY do, evaluated pre-action.
  • ECT (Execution Context Tokens, draft-nennemann-wimse-ect) — JWT-based execution records of what an agent DID, emitted post-action, with three assurance levels (L1 unsigned, L2 JOSE-signed, L3 JOSE-signed + external audit-ledger anchor).

The drafts are now mature enough to adopt as the wire formats. Doing so:

  • removes the "Trails-specific token format" dependency from the critical path,
  • makes Trails interoperable with any WIMSE/ACT-conformant system,
  • gives the drafts a reference implementation and a dogfood feedback loop.

The question is how ACT/ECT relate to the existing biscuit and PROV-O decisions — replace, layer, or supersede?

Decision

ACT and ECT are the native primitives for trails-identity (capability mandates) and trails-prov (activity records). Biscuit and PROV-O are retained in narrower roles.

  • trails-identity uses ACT tokens as the canonical capability mandate. ACT is the outer, interoperable format a principal presents to invoke a capability.
  • Biscuit is retained as an optional attenuation layer for short-lived session tokens derived from a parent ACT. Biscuit's Datalog caveats and attenuation semantics fit agent-chain delegation that ACT does not natively model.
  • trails-prov emits one ECT token per capability invocation. Assurance level is chosen via @capability(assurance="L1|L2|L3"):
  • L1 — default when the capability declares no side_effects.graph_writes.
  • L2 — default for capabilities with writes.
  • L3 — required when the capability declares side_effects.regulated: true (new manifest field).
  • ECT is the on-wire / exported form of an activity. Internally, the same activity is also materialized as PROV-O triples in the prov: named graph for SPARQL queryability.
  • Mapping is bidirectional: given an ECT the kernel derives PROV-O triples; given a PROV-O activity the kernel synthesizes an ECT for export. Both representations coexist; neither is the sole source of truth.

This updates but does not supersede ADR-0009 or ADR-0010. Provenance remains always-on (ADR-0009); biscuit remains supported (ADR-0010).

Consequences

Positive

  • Standards-track interoperability. Any WIMSE/ACT-aware client can consume Trails tokens without a Trails-specific parser.
  • Trails becomes the ACT/ECT reference implementation. Strengthens both drafts via production feedback.
  • Regulated-industry pitch sharpens. L3 ECT + audit ledger is a clean story for EU AI Act Art. 12, pharma, finance.
  • Provenance stays queryable. PROV-O named graph is untouched for SPARQL workloads.
  • Delegation story intact. Biscuit attenuation covers agent-chain cases ACT doesn't yet address.

Negative

  • Release cadence coupling. Trails tracks ACT/ECT draft evolution. Mitigated because the author controls the drafts; breaking changes are coordinated.
  • More tokens per request. An invocation may produce both an ACT (in) and an ECT (out). Mitigated by L1-default (unsigned, cheap) and signing only when the manifest demands it.
  • Two representations of each activity (ECT + PROV-O). Mitigated by kernel-owned bidirectional mapping; apps see one surface.

Non-consequences

  • HTTP/MCP transport choice (ADR-0008) unchanged.
  • Cedar policy engine (ADR-0006) unchanged — policy decisions still evaluate over the principal + capability, now sourced from an ACT.
  • Cost primitive (ADR-0012) unchanged; cost receipts can ride inside the ECT envelope but the accounting layer is the same.
  • DID identity (ADR-0011) unchanged; ACTs are issued to DIDs.

Revisit conditions

  • If ACT or ECT diverge from practical Trails needs during implementation, add Trails-specific extensions rather than forking — keep the core profile conformant.
  • If a competing IETF work product supersedes ACT or ECT, re-evaluate; prefer the surviving standard over a Trails private fork.
  • If the L3 audit-ledger story creates operational cost that outweighs the compliance value, consider making L3 opt-in per deployment rather than per capability.

Update (2026-04-12) — Normative security requirements

The following are normative at the kernel verifier / emitter boundary. They are not configurable down; deployments may tighten but not loosen.

  • (a) jti replay cache. trails-identity maintains an LRU cache of seen jti values, capacity 100 000 entries, TTL = exp of the token + configured max_clock_skew. Required at L2 and L3; strongly recommended at L1. A jti hit is a hard reject (TokenReplayed). Without this, a replayed ACT is indistinguishable from a fresh one.
  • (b) aud equality. Every token's aud claim MUST equal the kernel instance identifier (configured per deployment). Mismatched aud is rejected even when the signature validates — no substring match, no list-member match against a wildcard.
  • © nbf / exp clock skew. Default tolerance ±60 seconds, configurable. Tokens outside [nbf - skew, exp + skew] are rejected. The skew bound is part of the replay-cache TTL derivation.
  • (d) Revocation-list URL. ECTs at L2+ MAY carry an optional revocation_list URL. Consumers that require tight revocation semantics (regulated workloads at L3) MUST consult the list before accepting; the framework exposes a require_revocation_check: bool verifier flag that forces the check. If the list is unreachable and the flag is set, verification fails closed.
  • (e) Deny-path ECT emission. If policy denies after handler execution has already started (mid-stream revocation, output-validator failure, cost overrun), an ECT is still emitted with outcome: "denied" and the reason recorded. Deny paths MUST preserve the audit trail; silent drops break the always-on provenance posture of ADR-0009.

Ownership of the signing key

The ACT/ECT JOSE signing key lives in trails-identity, not trails-prov. The design spec §3.2 introduces a Signer trait

pub trait Signer: Send + Sync {
    fn sign(&self, payload: &[u8], key_id: &str) -> Result<Signature>;
    fn verify(&self, payload: &[u8], sig: &Signature, key_id: &str) -> Result<()>;
}

implemented by trails-identity. trails-prov calls the signer through this trait when emitting L2/L3 ECTs; it does not hold key material. Placing key ownership in the identity crate keeps cryptographic key custody in one place (where DID key material and biscuit root keys already live) and lets the provenance crate remain key-agnostic.