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-identityuses 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-provemits one ECT token per capability invocation. Assurance level is chosen via@capability(assurance="L1|L2|L3"):L1— default when the capability declares noside_effects.graph_writes.L2— default for capabilities with writes.L3— required when the capability declaresside_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)
jtireplay cache.trails-identitymaintains an LRU cache of seenjtivalues, capacity 100 000 entries, TTL =expof the token + configuredmax_clock_skew. Required at L2 and L3; strongly recommended at L1. Ajtihit is a hard reject (TokenReplayed). Without this, a replayed ACT is indistinguishable from a fresh one. - (b)
audequality. Every token'saudclaim MUST equal the kernel instance identifier (configured per deployment). Mismatchedaudis rejected even when the signature validates — no substring match, no list-member match against a wildcard. - ©
nbf/expclock 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_listURL. Consumers that require tight revocation semantics (regulated workloads at L3) MUST consult the list before accepting; the framework exposes arequire_revocation_check: boolverifier 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.