From 8715af5e54c1bcd657309d87c4c56323a30d44a9 Mon Sep 17 00:00:00 2001 From: Tymofii Pidlisnyi Date: Thu, 5 Mar 2026 11:15:33 -0800 Subject: [PATCH 1/5] proposal: cascade revocation spec for agent delegation chains --- docs/cascade-revocation.md | 218 +++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 docs/cascade-revocation.md diff --git a/docs/cascade-revocation.md b/docs/cascade-revocation.md new file mode 100644 index 0000000..c3068e0 --- /dev/null +++ b/docs/cascade-revocation.md @@ -0,0 +1,218 @@ +# Cascade Revocation for Agent Identity Protocols + +**Version:** 0.1.0 +**Author:** Tymofii Pidlisnyi (@aeoess) +**Date:** March 2026 +**Status:** Draft Proposal +**Reference Implementation:** [agent-passport-system](https://github.com/aeoess/agent-passport-system) (Apache-2.0) + +--- + +## 1. Problem + +When AI agents delegate authority to other agents, revocation must propagate. If Agent A delegates to Agent B, and Agent B sub-delegates to Agent C, revoking A's delegation to B must also invalidate C's authority. Without cascade revocation, revoking a compromised agent leaves its downstream delegations active. + +This is not a theoretical concern. In multi-agent systems where agents autonomously sub-delegate to specialists, a single compromised delegation can create an unbounded tree of active permissions that the human principal cannot recall. + +No current agent identity protocol specifies how revocation propagates through delegation chains. + +## 2. Data Structures + +### 2.1 Delegation + +```typescript +interface Delegation { + delegationId: string // Unique identifier (UUID v4) + delegatorId: string // Agent granting authority + delegateId: string // Agent receiving authority + scope: string[] // Permitted action scopes + spendLimit?: number // Maximum spend in delegation currency + maxDepth: number // Maximum sub-delegation depth allowed + currentDepth: number // Current depth in the chain (0 = root) + expiresAt: string // ISO 8601 expiration timestamp + createdAt: string // ISO 8601 creation timestamp + parentDelegationId?: string // ID of parent delegation (null for root) + revoked: boolean // Revocation status + revokedAt?: string // ISO 8601 revocation timestamp + revokedReason?: string // Human-readable revocation reason + signature: string // Ed25519 signature by delegator +} +``` + +### 2.2 Revocation Record + +```typescript +interface RevocationRecord { + delegationId: string // Delegation being revoked + revokedBy: string // Agent performing the revocation + revokedAt: string // ISO 8601 timestamp + reason: string // Revocation reason + cascadeCount: number // Number of downstream delegations also revoked + signature: string // Ed25519 signature by revoker +} +``` + +### 2.3 Chain Registry + +The chain registry tracks parent-child relationships between delegations. It MUST be populated at delegation creation time, not reconstructed at revocation time. + +```typescript +interface ChainRegistry { + // Maps delegationId -> list of child delegationIds + children: Map + // Maps delegationId -> parent delegationId + parents: Map +} +``` + +**Rationale:** Reconstructing the tree at revocation time requires scanning all delegations, which is O(n) in the total number of delegations. The registry makes cascade O(k) where k is the number of descendants. + +## 3. Algorithm + +### 3.1 Cascade Revocation + +When a delegation is revoked, all downstream delegations MUST be revoked synchronously. + +``` +function cascadeRevoke(delegationId, reason, revokerKey): + 1. Look up delegation in store + 2. If already revoked, return (idempotent, no error) + 3. Mark delegation as revoked (set revoked=true, revokedAt, reason) + 4. Sign revocation record with revokerKey + 5. Get all children from chain registry + 6. For each child: + a. Recursively call cascadeRevoke(child.delegationId, reason, revokerKey) + 7. Emit revocation event + 8. Return RevocationRecord with cascadeCount +``` + +**Properties:** +- **Synchronous:** All descendants are revoked in the same operation. No eventual consistency. +- **Deterministic:** Same input always produces the same result. +- **Idempotent:** Revoking an already-revoked delegation is a no-op, not an error. +- **Total:** There is no partial cascade. Either all descendants are revoked or the operation fails atomically. + +### 3.2 Batch Revocation by Agent + +Revoke all delegations granted TO a specific agent, with cascade. + +``` +function batchRevokeByAgent(agentId, reason, revokerKey): + 1. Find all delegations where delegateId == agentId + 2. For each delegation: + a. Call cascadeRevoke(delegation.delegationId, reason, revokerKey) + 3. Return list of RevocationRecords +``` + +**Use case:** An agent is compromised. The human principal revokes everything granted to that agent. All sub-delegations that agent created also die. + +### 3.3 Chain Validation + +Before any action is permitted, the entire delegation chain from the acting agent back to the human principal MUST be validated. + +``` +function validateChain(delegationIds[]): + 1. For each delegation in chain: + a. If revoked: return {valid: false, error: "revoked link"} + b. If expired: return {valid: false, error: "expired link"} + c. If not found in registry: return {valid: false, error: "unknown delegation"} + 2. For each adjacent pair (parent, child): + a. If parent.delegateId != child.delegatorId: return {valid: false, error: "chain break"} + 3. Return {valid: true} +``` + +## 4. Sub-delegation Constraints + +When an agent sub-delegates, the child delegation MUST be strictly narrower than the parent: + +- **Scope:** Child scope MUST be a subset of parent scope. scopeCovers(parentScope, childScope) must be true for every scope in the child. +- **Spend limit:** Child spend limit MUST NOT exceed parent spend limit. +- **Depth:** Child currentDepth MUST equal parent currentDepth + 1. Child currentDepth MUST NOT exceed parent maxDepth. +- **Expiry:** Child expiration MUST NOT exceed parent expiration. + +Violation of any constraint MUST cause sub-delegation to fail. This ensures that cascade revocation is always safe: revoking a parent can never leave a child with more authority than the parent had. + +## 5. Security Properties + +### 5.1 No Orphan Delegations + +Every non-root delegation MUST have a traceable parent in the chain registry. If a parent delegation is deleted (not just revoked), all children MUST be cascade-revoked first. + +### 5.2 No Double-Revoke Side Effects + +Revoking an already-revoked delegation MUST be idempotent. It MUST NOT re-emit events, re-sign records, or increment cascade counts. Implementations SHOULD check revocation status before traversing children. + +### 5.3 Revocation is Irreversible + +A revoked delegation CANNOT be un-revoked. If the same authority is needed again, a new delegation MUST be created. This prevents time-of-check/time-of-use attacks where a revoked delegation is temporarily reinstated. + +### 5.4 Branching Chains + +A single delegation MAY have multiple children (Agent A delegates to both Agent B and Agent C). Cascade revocation MUST traverse all branches. The traversal order is not specified, but all branches MUST be revoked before the operation returns. + +### 5.5 Event Propagation + +Implementations SHOULD support revocation event subscriptions so that dependent systems (task schedulers, commerce gateways, policy engines) can react to revocations in real time. + +```typescript +interface RevocationEvent { + delegationId: string + cascadeDepth: number // 0 for the directly revoked delegation + totalCascaded: number // Running count of all revoked in this cascade +} +``` + +## 6. Integration with Policy Engines + +Cascade revocation is an identity-layer primitive. Policy engines (such as AIP's AgentPolicy) SHOULD reference delegation chain validity as a precondition for policy evaluation. + +Suggested integration pattern: + +``` +1. Agent presents action request with delegationId +2. Policy engine calls validateChain() on the delegation chain +3. If chain is invalid (any link revoked/expired), deny immediately +4. If chain is valid, proceed to policy evaluation (scope, rate limits, etc.) +``` + +This ensures that revocation takes effect immediately without requiring policy engines to maintain their own revocation state. + +## 7. Adversarial Scenarios + +The following attacks MUST be mitigated by any conforming implementation: + +| Attack | Mitigation | +|--------|-----------| +| Replay revoked delegation | Chain validation checks revoked flag before any action | +| Sub-delegate after revocation | createReceipt/subDelegate checks delegation validity | +| Scope escalation via sub-delegation | Sub-delegation enforces strict scope narrowing | +| Spend limit escalation | Child spend limit capped at parent spend limit | +| Depth bomb (unbounded sub-delegation) | maxDepth enforced at sub-delegation time | +| Double-revoke event spam | Idempotent revocation, no re-emit on already-revoked | +| Orphan delegation after parent delete | Cascade revocation before deletion | + +## 8. Reference Implementation + +The Agent Passport System SDK implements this specification: + +- **npm:** agent-passport-system (v1.9.2) +- **Source:** [github.com/aeoess/agent-passport-system](https://github.com/aeoess/agent-passport-system) +- **File:** src/core/delegation.ts (~520 lines) +- **Tests:** 276 tests, 73 suites, including 23 adversarial scenarios +- **License:** Apache-2.0 + +Key functions: cascadeRevoke(), batchRevokeByAgent(), validateChain(), getDescendants(), onRevocation() + +--- + +## Appendix A: Scope Resolution + +Cascade revocation depends on correct scope matching for sub-delegation validation. The reference implementation uses a single scopeCovers(granted, required) function: + +- Exact match: code covers code +- Hierarchical: code covers code:deploy (parent covers child) +- Universal wildcard: * covers everything +- Prefix wildcard: commerce:* covers commerce and commerce:checkout +- No reverse: code:deploy does NOT cover code + +All scope checks across the protocol (delegation, policy evaluation, context enforcement, commerce validation) MUST use the same matching function to prevent inconsistencies. From af73a7553fa51129daf2c4afe098e7c663917d5e Mon Sep 17 00:00:00 2001 From: Tymofii Pidlisnyi Date: Thu, 12 Mar 2026 10:19:08 -0700 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20parentDelegationId=20type=20?= =?UTF-8?q?=E2=80=94=20string=20|=20null=20(not=20optional)=20to=20match?= =?UTF-8?q?=20null-for-root=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review: optional (?) implies undefined when absent, but the comment specifies null for root delegations. Changed to explicit string | null union type for consistency with ChainRegistry.parents map behavior. --- docs/cascade-revocation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cascade-revocation.md b/docs/cascade-revocation.md index c3068e0..156061b 100644 --- a/docs/cascade-revocation.md +++ b/docs/cascade-revocation.md @@ -31,7 +31,7 @@ interface Delegation { currentDepth: number // Current depth in the chain (0 = root) expiresAt: string // ISO 8601 expiration timestamp createdAt: string // ISO 8601 creation timestamp - parentDelegationId?: string // ID of parent delegation (null for root) + parentDelegationId: string | null // ID of parent delegation (null for root) revoked: boolean // Revocation status revokedAt?: string // ISO 8601 revocation timestamp revokedReason?: string // Human-readable revocation reason From 763d5cbe2f16e85c1897177fed96c25153bf620e Mon Sep 17 00:00:00 2001 From: Tymofii Pidlisnyi Date: Thu, 12 Mar 2026 10:30:01 -0700 Subject: [PATCH 3/5] rewrite: cascade revocation as JSON schema + Mintlify .mdx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback from @JamesCao: - Rewrote data structures as JSON Schema (2020-12 draft) matching AIP conventions - Moved spec to proposals/ directory - Converted docs to Mintlify .mdx format - Removed SDK reference — spec is now protocol-agnostic - Fixed parentDelegationId type: string | null (not optional) --- docs/cascade-revocation.md | 218 --------------------- docs/proposals/cascade-revocation.mdx | 133 +++++++++++++ spec/schema/cascade-revocation.schema.json | 167 ++++++++++++++++ 3 files changed, 300 insertions(+), 218 deletions(-) delete mode 100644 docs/cascade-revocation.md create mode 100644 docs/proposals/cascade-revocation.mdx create mode 100644 spec/schema/cascade-revocation.schema.json diff --git a/docs/cascade-revocation.md b/docs/cascade-revocation.md deleted file mode 100644 index 156061b..0000000 --- a/docs/cascade-revocation.md +++ /dev/null @@ -1,218 +0,0 @@ -# Cascade Revocation for Agent Identity Protocols - -**Version:** 0.1.0 -**Author:** Tymofii Pidlisnyi (@aeoess) -**Date:** March 2026 -**Status:** Draft Proposal -**Reference Implementation:** [agent-passport-system](https://github.com/aeoess/agent-passport-system) (Apache-2.0) - ---- - -## 1. Problem - -When AI agents delegate authority to other agents, revocation must propagate. If Agent A delegates to Agent B, and Agent B sub-delegates to Agent C, revoking A's delegation to B must also invalidate C's authority. Without cascade revocation, revoking a compromised agent leaves its downstream delegations active. - -This is not a theoretical concern. In multi-agent systems where agents autonomously sub-delegate to specialists, a single compromised delegation can create an unbounded tree of active permissions that the human principal cannot recall. - -No current agent identity protocol specifies how revocation propagates through delegation chains. - -## 2. Data Structures - -### 2.1 Delegation - -```typescript -interface Delegation { - delegationId: string // Unique identifier (UUID v4) - delegatorId: string // Agent granting authority - delegateId: string // Agent receiving authority - scope: string[] // Permitted action scopes - spendLimit?: number // Maximum spend in delegation currency - maxDepth: number // Maximum sub-delegation depth allowed - currentDepth: number // Current depth in the chain (0 = root) - expiresAt: string // ISO 8601 expiration timestamp - createdAt: string // ISO 8601 creation timestamp - parentDelegationId: string | null // ID of parent delegation (null for root) - revoked: boolean // Revocation status - revokedAt?: string // ISO 8601 revocation timestamp - revokedReason?: string // Human-readable revocation reason - signature: string // Ed25519 signature by delegator -} -``` - -### 2.2 Revocation Record - -```typescript -interface RevocationRecord { - delegationId: string // Delegation being revoked - revokedBy: string // Agent performing the revocation - revokedAt: string // ISO 8601 timestamp - reason: string // Revocation reason - cascadeCount: number // Number of downstream delegations also revoked - signature: string // Ed25519 signature by revoker -} -``` - -### 2.3 Chain Registry - -The chain registry tracks parent-child relationships between delegations. It MUST be populated at delegation creation time, not reconstructed at revocation time. - -```typescript -interface ChainRegistry { - // Maps delegationId -> list of child delegationIds - children: Map - // Maps delegationId -> parent delegationId - parents: Map -} -``` - -**Rationale:** Reconstructing the tree at revocation time requires scanning all delegations, which is O(n) in the total number of delegations. The registry makes cascade O(k) where k is the number of descendants. - -## 3. Algorithm - -### 3.1 Cascade Revocation - -When a delegation is revoked, all downstream delegations MUST be revoked synchronously. - -``` -function cascadeRevoke(delegationId, reason, revokerKey): - 1. Look up delegation in store - 2. If already revoked, return (idempotent, no error) - 3. Mark delegation as revoked (set revoked=true, revokedAt, reason) - 4. Sign revocation record with revokerKey - 5. Get all children from chain registry - 6. For each child: - a. Recursively call cascadeRevoke(child.delegationId, reason, revokerKey) - 7. Emit revocation event - 8. Return RevocationRecord with cascadeCount -``` - -**Properties:** -- **Synchronous:** All descendants are revoked in the same operation. No eventual consistency. -- **Deterministic:** Same input always produces the same result. -- **Idempotent:** Revoking an already-revoked delegation is a no-op, not an error. -- **Total:** There is no partial cascade. Either all descendants are revoked or the operation fails atomically. - -### 3.2 Batch Revocation by Agent - -Revoke all delegations granted TO a specific agent, with cascade. - -``` -function batchRevokeByAgent(agentId, reason, revokerKey): - 1. Find all delegations where delegateId == agentId - 2. For each delegation: - a. Call cascadeRevoke(delegation.delegationId, reason, revokerKey) - 3. Return list of RevocationRecords -``` - -**Use case:** An agent is compromised. The human principal revokes everything granted to that agent. All sub-delegations that agent created also die. - -### 3.3 Chain Validation - -Before any action is permitted, the entire delegation chain from the acting agent back to the human principal MUST be validated. - -``` -function validateChain(delegationIds[]): - 1. For each delegation in chain: - a. If revoked: return {valid: false, error: "revoked link"} - b. If expired: return {valid: false, error: "expired link"} - c. If not found in registry: return {valid: false, error: "unknown delegation"} - 2. For each adjacent pair (parent, child): - a. If parent.delegateId != child.delegatorId: return {valid: false, error: "chain break"} - 3. Return {valid: true} -``` - -## 4. Sub-delegation Constraints - -When an agent sub-delegates, the child delegation MUST be strictly narrower than the parent: - -- **Scope:** Child scope MUST be a subset of parent scope. scopeCovers(parentScope, childScope) must be true for every scope in the child. -- **Spend limit:** Child spend limit MUST NOT exceed parent spend limit. -- **Depth:** Child currentDepth MUST equal parent currentDepth + 1. Child currentDepth MUST NOT exceed parent maxDepth. -- **Expiry:** Child expiration MUST NOT exceed parent expiration. - -Violation of any constraint MUST cause sub-delegation to fail. This ensures that cascade revocation is always safe: revoking a parent can never leave a child with more authority than the parent had. - -## 5. Security Properties - -### 5.1 No Orphan Delegations - -Every non-root delegation MUST have a traceable parent in the chain registry. If a parent delegation is deleted (not just revoked), all children MUST be cascade-revoked first. - -### 5.2 No Double-Revoke Side Effects - -Revoking an already-revoked delegation MUST be idempotent. It MUST NOT re-emit events, re-sign records, or increment cascade counts. Implementations SHOULD check revocation status before traversing children. - -### 5.3 Revocation is Irreversible - -A revoked delegation CANNOT be un-revoked. If the same authority is needed again, a new delegation MUST be created. This prevents time-of-check/time-of-use attacks where a revoked delegation is temporarily reinstated. - -### 5.4 Branching Chains - -A single delegation MAY have multiple children (Agent A delegates to both Agent B and Agent C). Cascade revocation MUST traverse all branches. The traversal order is not specified, but all branches MUST be revoked before the operation returns. - -### 5.5 Event Propagation - -Implementations SHOULD support revocation event subscriptions so that dependent systems (task schedulers, commerce gateways, policy engines) can react to revocations in real time. - -```typescript -interface RevocationEvent { - delegationId: string - cascadeDepth: number // 0 for the directly revoked delegation - totalCascaded: number // Running count of all revoked in this cascade -} -``` - -## 6. Integration with Policy Engines - -Cascade revocation is an identity-layer primitive. Policy engines (such as AIP's AgentPolicy) SHOULD reference delegation chain validity as a precondition for policy evaluation. - -Suggested integration pattern: - -``` -1. Agent presents action request with delegationId -2. Policy engine calls validateChain() on the delegation chain -3. If chain is invalid (any link revoked/expired), deny immediately -4. If chain is valid, proceed to policy evaluation (scope, rate limits, etc.) -``` - -This ensures that revocation takes effect immediately without requiring policy engines to maintain their own revocation state. - -## 7. Adversarial Scenarios - -The following attacks MUST be mitigated by any conforming implementation: - -| Attack | Mitigation | -|--------|-----------| -| Replay revoked delegation | Chain validation checks revoked flag before any action | -| Sub-delegate after revocation | createReceipt/subDelegate checks delegation validity | -| Scope escalation via sub-delegation | Sub-delegation enforces strict scope narrowing | -| Spend limit escalation | Child spend limit capped at parent spend limit | -| Depth bomb (unbounded sub-delegation) | maxDepth enforced at sub-delegation time | -| Double-revoke event spam | Idempotent revocation, no re-emit on already-revoked | -| Orphan delegation after parent delete | Cascade revocation before deletion | - -## 8. Reference Implementation - -The Agent Passport System SDK implements this specification: - -- **npm:** agent-passport-system (v1.9.2) -- **Source:** [github.com/aeoess/agent-passport-system](https://github.com/aeoess/agent-passport-system) -- **File:** src/core/delegation.ts (~520 lines) -- **Tests:** 276 tests, 73 suites, including 23 adversarial scenarios -- **License:** Apache-2.0 - -Key functions: cascadeRevoke(), batchRevokeByAgent(), validateChain(), getDescendants(), onRevocation() - ---- - -## Appendix A: Scope Resolution - -Cascade revocation depends on correct scope matching for sub-delegation validation. The reference implementation uses a single scopeCovers(granted, required) function: - -- Exact match: code covers code -- Hierarchical: code covers code:deploy (parent covers child) -- Universal wildcard: * covers everything -- Prefix wildcard: commerce:* covers commerce and commerce:checkout -- No reverse: code:deploy does NOT cover code - -All scope checks across the protocol (delegation, policy evaluation, context enforcement, commerce validation) MUST use the same matching function to prevent inconsistencies. diff --git a/docs/proposals/cascade-revocation.mdx b/docs/proposals/cascade-revocation.mdx new file mode 100644 index 0000000..69cc90a --- /dev/null +++ b/docs/proposals/cascade-revocation.mdx @@ -0,0 +1,133 @@ +--- +title: "Cascade Revocation for Agent Delegation Chains" +description: "Proposal for propagating revocation through agent delegation trees" +--- + +## Problem + +When agents delegate authority to other agents, revocation must propagate. If Agent A delegates to Agent B, and B sub-delegates to C, revoking A→B must also invalidate C's authority. Without cascade revocation, revoking a compromised agent leaves its downstream delegations active. + +In multi-agent systems where agents autonomously sub-delegate to specialists, a single compromised delegation can create an unbounded tree of active permissions that the human principal cannot recall. + +## Data Structures + +All data structures are defined in [cascade-revocation.schema.json](/spec/schema/cascade-revocation.schema.json). + +The core objects are: + +- **Delegation** — authority granted from one agent to another, with scope, spend limit, depth limit, and expiry +- **RevocationRecord** — signed proof that a delegation was revoked, including cascade count +- **ChainRegistry** — parent-child index populated at delegation creation time (not reconstructed at revocation time) +- **RevocationEvent** — event emitted for each revoked delegation in a cascade + +The `parentDelegationId` field uses `string | null` (not optional). Root delegations set this to `null` explicitly. This aligns with the ChainRegistry.parents map behavior. + +## Algorithm + +### Cascade Revocation + +When a delegation is revoked, all downstream delegations MUST be revoked synchronously. + +``` +cascadeRevoke(delegationId, reason, revokerKey): + 1. Look up delegation in store + 2. If already revoked, return (idempotent) + 3. Mark delegation as revoked (revoked=true, revokedAt, reason) + 4. Sign revocation record with revokerKey + 5. Get all children from chain registry + 6. For each child: recursively cascadeRevoke(child, reason, revokerKey) + 7. Emit revocation event + 8. Return RevocationRecord with cascadeCount +``` + +**Properties:** +- **Synchronous** — all descendants revoked in the same operation. No eventual consistency. +- **Deterministic** — same input always produces the same result. +- **Idempotent** — revoking an already-revoked delegation is a no-op. +- **Total** — no partial cascade. All descendants revoked or operation fails atomically. + +### Batch Revocation by Agent + +Revoke all delegations granted TO a specific agent, with cascade. + +``` +batchRevokeByAgent(agentId, reason, revokerKey): + 1. Find all delegations where delegateId == agentId + 2. For each: cascadeRevoke(delegation, reason, revokerKey) + 3. Return list of RevocationRecords +``` + +Use case: an agent is compromised. The principal revokes everything granted to that agent. All sub-delegations also die. + +### Chain Validation + +Before any action is permitted, the entire delegation chain back to the principal MUST be validated. + +``` +validateChain(delegationIds[]): + 1. For each delegation in chain: + - If revoked: return invalid + - If expired: return invalid + - If not in registry: return invalid + 2. For each adjacent pair (parent, child): + - If parent.delegateId != child.delegatorId: return invalid + 3. Return valid +``` + +## Sub-delegation Constraints + +When an agent sub-delegates, the child MUST be strictly narrower than the parent: + +- **Scope** — child scope MUST be a subset of parent scope +- **Spend limit** — child MUST NOT exceed parent spend limit +- **Depth** — child currentDepth MUST equal parent currentDepth + 1, MUST NOT exceed parent maxDepth +- **Expiry** — child MUST NOT exceed parent expiration + +Violation of any constraint MUST cause sub-delegation to fail. + +## Security Properties + +| Property | Requirement | +|----------|------------| +| No orphan delegations | Every non-root delegation MUST have a traceable parent | +| Idempotent revocation | Double-revoke MUST NOT re-emit events or increment counts | +| Irreversible | Revoked delegations CANNOT be un-revoked | +| Branching | Cascade MUST traverse all branches of multi-child delegations | +| Event propagation | Implementations SHOULD support revocation event subscriptions | + +## Integration with Policy Engines + +Cascade revocation is an identity-layer primitive. Policy engines (such as AIP's AgentPolicy) SHOULD reference delegation chain validity as a precondition for policy evaluation. + +``` +1. Agent presents action request with delegationId +2. Policy engine calls validateChain() on the delegation chain +3. If chain invalid (any link revoked/expired), deny immediately +4. If chain valid, proceed to policy evaluation +``` + +This ensures revocation takes effect immediately without requiring policy engines to maintain their own revocation state. + +## Adversarial Scenarios + +Any conforming implementation MUST mitigate the following: + +| Attack | Mitigation | +|--------|-----------| +| Replay revoked delegation | Chain validation checks revoked flag before any action | +| Sub-delegate after revocation | Sub-delegation checks delegation validity | +| Scope escalation via sub-delegation | Strict scope narrowing enforced | +| Spend limit escalation | Child spend limit capped at parent | +| Depth bomb (unbounded sub-delegation) | maxDepth enforced at sub-delegation time | +| Double-revoke event spam | Idempotent revocation | +| Orphan delegation after parent delete | Cascade revocation before deletion | + +## Scope Resolution + +Cascade revocation depends on correct scope matching. All implementations MUST use a single scope matching function: + +- Exact match: `code` covers `code` +- Hierarchical: `code` covers `code:deploy` +- Universal wildcard: `*` covers everything +- Prefix wildcard: `commerce:*` covers `commerce` and `commerce:checkout` +- No reverse: `code:deploy` does NOT cover `code` diff --git a/spec/schema/cascade-revocation.schema.json b/spec/schema/cascade-revocation.schema.json new file mode 100644 index 0000000..6630de2 --- /dev/null +++ b/spec/schema/cascade-revocation.schema.json @@ -0,0 +1,167 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aip.io/schema/proposals/cascade-revocation.schema.json", + "title": "Cascade Revocation", + "description": "Data structures for cascade revocation in agent delegation chains", + "type": "object", + "$defs": { + "Delegation": { + "type": "object", + "description": "A delegation of authority from one agent to another", + "required": ["delegationId", "delegatorId", "delegateId", "scope", "maxDepth", "currentDepth", "expiresAt", "createdAt", "parentDelegationId", "revoked", "signature"], + "additionalProperties": false, + "properties": { + "delegationId": { + "type": "string", + "format": "uuid", + "description": "Unique identifier (UUID v4)" + }, + "delegatorId": { + "type": "string", + "description": "Agent granting authority" + }, + "delegateId": { + "type": "string", + "description": "Agent receiving authority" + }, + "scope": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "description": "Permitted action scopes" + }, + "spendLimit": { + "type": "number", + "minimum": 0, + "description": "Maximum spend in delegation currency" + }, + "maxDepth": { + "type": "integer", + "minimum": 0, + "description": "Maximum sub-delegation depth allowed" + }, + "currentDepth": { + "type": "integer", + "minimum": 0, + "description": "Current depth in the chain (0 = root)" + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 expiration timestamp" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 creation timestamp" + }, + "parentDelegationId": { + "oneOf": [ + { "type": "string", "format": "uuid" }, + { "type": "null" } + ], + "description": "ID of parent delegation (null for root)" + }, + "revoked": { + "type": "boolean", + "default": false, + "description": "Revocation status" + }, + "revokedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 revocation timestamp" + }, + "revokedReason": { + "type": "string", + "description": "Human-readable revocation reason" + }, + "signature": { + "type": "string", + "description": "Ed25519 signature by delegator" + } + } + }, + "RevocationRecord": { + "type": "object", + "description": "Record of a delegation revocation", + "required": ["delegationId", "revokedBy", "revokedAt", "reason", "cascadeCount", "signature"], + "additionalProperties": false, + "properties": { + "delegationId": { + "type": "string", + "format": "uuid", + "description": "Delegation being revoked" + }, + "revokedBy": { + "type": "string", + "description": "Agent performing the revocation" + }, + "revokedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp" + }, + "reason": { + "type": "string", + "description": "Revocation reason" + }, + "cascadeCount": { + "type": "integer", + "minimum": 0, + "description": "Number of downstream delegations also revoked" + }, + "signature": { + "type": "string", + "description": "Ed25519 signature by revoker" + } + } + }, + "RevocationEvent": { + "type": "object", + "description": "Event emitted when a delegation is revoked", + "required": ["delegationId", "cascadeDepth", "totalCascaded"], + "properties": { + "delegationId": { + "type": "string", + "format": "uuid" + }, + "cascadeDepth": { + "type": "integer", + "minimum": 0, + "description": "0 for the directly revoked delegation" + }, + "totalCascaded": { + "type": "integer", + "minimum": 0, + "description": "Running count of all revoked in this cascade" + } + } + }, + "ChainRegistry": { + "type": "object", + "description": "Tracks parent-child relationships between delegations. MUST be populated at delegation creation time.", + "required": ["children", "parents"], + "properties": { + "children": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string", "format": "uuid" } + }, + "description": "Maps delegationId to list of child delegationIds" + }, + "parents": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { "type": "string", "format": "uuid" }, + { "type": "null" } + ] + }, + "description": "Maps delegationId to parent delegationId (null for root)" + } + } + } + } +} From 0905c6a310c5ce19a777291d5f09984ea5e17fdf Mon Sep 17 00:00:00 2001 From: Tymofii Pidlisnyi Date: Thu, 12 Mar 2026 13:02:57 -0700 Subject: [PATCH 4/5] address all Copilot review feedback - Add currency field (ISO 4217) to Delegation schema - Fix cascadeRevoke pseudocode: childId not child.delegationId - Add explicit registry lookup step to validateChain - Clarify scopeCovers array algorithm for sub-delegation - Define transactional semantics for Total property - Document best-effort fallback for stores without transactions --- docs/proposals/cascade-revocation.mdx | 25 +++++++++++++--------- spec/schema/cascade-revocation.schema.json | 6 +++++- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/proposals/cascade-revocation.mdx b/docs/proposals/cascade-revocation.mdx index 69cc90a..68105da 100644 --- a/docs/proposals/cascade-revocation.mdx +++ b/docs/proposals/cascade-revocation.mdx @@ -30,12 +30,12 @@ When a delegation is revoked, all downstream delegations MUST be revoked synchro ``` cascadeRevoke(delegationId, reason, revokerKey): - 1. Look up delegation in store + 1. Look up delegation in store by delegationId 2. If already revoked, return (idempotent) 3. Mark delegation as revoked (revoked=true, revokedAt, reason) 4. Sign revocation record with revokerKey - 5. Get all children from chain registry - 6. For each child: recursively cascadeRevoke(child, reason, revokerKey) + 5. Get all child delegationIds from chain registry + 6. For each childId: recursively cascadeRevoke(childId, reason, revokerKey) 7. Emit revocation event 8. Return RevocationRecord with cascadeCount ``` @@ -44,7 +44,7 @@ cascadeRevoke(delegationId, reason, revokerKey): - **Synchronous** — all descendants revoked in the same operation. No eventual consistency. - **Deterministic** — same input always produces the same result. - **Idempotent** — revoking an already-revoked delegation is a no-op. -- **Total** — no partial cascade. All descendants revoked or operation fails atomically. +- **Total (Transactional)** — no partial cascade. Either all descendants are revoked and their revocation records durably persisted, or none are. Implementations MUST execute steps 3-7 for the entire cascade within a single atomic transaction or equivalent mechanism that guarantees all-or-nothing visibility. In systems without multi-record transactions, implementations MUST document that cascade is best-effort and may leave partial state on failure. ### Batch Revocation by Agent @@ -65,12 +65,17 @@ Before any action is permitted, the entire delegation chain back to the principa ``` validateChain(delegationIds[]): - 1. For each delegation in chain: + 0. Resolve delegations from registry: + - For each delegationId in delegationIds (in order provided): + - Lookup delegation in registry by delegationId + - If not found: return invalid ("unknown delegation") + - Append to orderedDelegations[] + 1. For each delegation in orderedDelegations: - If revoked: return invalid - If expired: return invalid - - If not in registry: return invalid - 2. For each adjacent pair (parent, child): - - If parent.delegateId != child.delegatorId: return invalid + 2. For each adjacent pair (parent, child) in orderedDelegations: + - If parent.delegationId != child.parentDelegationId: return invalid ("invalid parent reference") + - If parent.delegateId != child.delegatorId: return invalid ("chain break") 3. Return valid ``` @@ -78,8 +83,8 @@ validateChain(delegationIds[]): When an agent sub-delegates, the child MUST be strictly narrower than the parent: -- **Scope** — child scope MUST be a subset of parent scope -- **Spend limit** — child MUST NOT exceed parent spend limit +- **Scope** — child scope MUST be a subset of parent scope. For array-valued scopes: for each `requiredScope` in the child's scope array, there MUST exist some `grantedScope` in the parent's scope array such that `scopeCovers(grantedScope, requiredScope)` is true, where `scopeCovers` operates on individual scope strings. +- **Spend limit** — child MUST NOT exceed parent spend limit. Currency is explicit (ISO 4217) or inherited from protocol default. - **Depth** — child currentDepth MUST equal parent currentDepth + 1, MUST NOT exceed parent maxDepth - **Expiry** — child MUST NOT exceed parent expiration diff --git a/spec/schema/cascade-revocation.schema.json b/spec/schema/cascade-revocation.schema.json index 6630de2..e34fd2f 100644 --- a/spec/schema/cascade-revocation.schema.json +++ b/spec/schema/cascade-revocation.schema.json @@ -30,10 +30,14 @@ "minItems": 1, "description": "Permitted action scopes" }, + "currency": { + "type": "string", + "description": "Delegation currency (ISO 4217 code, e.g. 'USD'). If omitted, protocol default applies" + }, "spendLimit": { "type": "number", "minimum": 0, - "description": "Maximum spend in delegation currency" + "description": "Maximum spend in smallest unit of currency (e.g. cents). If omitted, no spend constraint" }, "maxDepth": { "type": "integer", From 81ce890f88f8ffc491384c17af2b8aeca95d508c Mon Sep 17 00:00:00 2001 From: aeoess Date: Fri, 3 Jul 2026 10:32:17 -0700 Subject: [PATCH 5/5] extend RevocationRecord, add cascade-completion record and canonical-JSON vectors Implements the spec revision committed at PR #16 comment 4878250908: - RevocationRecord: add machine-readable reasonCode enum with optional freetext detail (replacing the freetext reason field), transactionId, cascadeOrigin, and optional evidenceRef; document revokedBy as the authority reference and revokedAt as issuer-signed inside the envelope - Conditional: reasonCode=parent_revoked requires cascadeOrigin, so cascade- derived records always name their origin - Add CascadeCompletionRecord: signed, one per transactionId, emitted after the last descendant persists. Its presence distinguishes "subtree fully closed" from "revocation in progress" using the signed records alone - Name the state-vs-evidence split (ChainRegistry is state, RevocationRecord and CascadeCompletionRecord are evidence) and state the two-question validator test as normative - Thread transactionId, reasonCode, and cascadeOrigin through the cascade pseudocode; add a distinct entry point that emits the completion record - Add canonical-JSON conformance vectors (RFC 8785 JCS) carrying both the wire form and the signing-input preimage for TypeScript/Python byte-parity, including the cascade-completion case --- docs/proposals/cascade-revocation.mdx | 86 +++++++++++++++--- .../cascade-revocation/vectors.json | 86 ++++++++++++++++++ spec/schema/cascade-revocation.schema.json | 87 +++++++++++++++++-- 3 files changed, 239 insertions(+), 20 deletions(-) create mode 100644 spec/conformance/cascade-revocation/vectors.json diff --git a/docs/proposals/cascade-revocation.mdx b/docs/proposals/cascade-revocation.mdx index 68105da..dbaf3b5 100644 --- a/docs/proposals/cascade-revocation.mdx +++ b/docs/proposals/cascade-revocation.mdx @@ -16,12 +16,27 @@ All data structures are defined in [cascade-revocation.schema.json](/spec/schema The core objects are: - **Delegation** — authority granted from one agent to another, with scope, spend limit, depth limit, and expiry -- **RevocationRecord** — signed proof that a delegation was revoked, including cascade count +- **RevocationRecord** — signed evidence that one delegation was revoked. Carries `revokedBy`, an issuer-signed `revokedAt`, a machine-readable `reasonCode` with optional freetext `detail`, the cascade `transactionId`, `cascadeOrigin` on cascade-derived records, `cascadeCount`, and an optional `evidenceRef` +- **CascadeCompletionRecord** — signed evidence that a cascade transaction closed, emitted once per `transactionId` after the last descendant's revocation persists - **ChainRegistry** — parent-child index populated at delegation creation time (not reconstructed at revocation time) - **RevocationEvent** — event emitted for each revoked delegation in a cascade The `parentDelegationId` field uses `string | null` (not optional). Root delegations set this to `null` explicitly. This aligns with the ChainRegistry.parents map behavior. +## State vs Evidence + +The design keeps two concerns separate, and a conforming implementation MUST NOT collapse them into a single mutable lookup: + +- **State (current validity)** — held in the `ChainRegistry` and each `Delegation.revoked` flag. It answers "is this delegation valid right now?" as a fast, mutable index. +- **Evidence (revocation history)** — held in the signed `RevocationRecord` and `CascadeCompletionRecord` objects. It answers "was this revoked, by whom, when, and under which cascade?" as immutable, independently verifiable records. + +A validator MUST be able to answer both of the following questions, and neither answer may depend on the other's data source: + +1. **Current validity.** Answerable from state alone (`ChainRegistry` plus `Delegation.revoked`), without reading any signed record. +2. **Revocation history.** Answerable from the signed records alone (`RevocationRecord` plus `CascadeCompletionRecord`), without trusting mutable state. + +If an implementation reduces question 2 to a mutable state lookup, it fails conformance. Revocation history would then be only as trustworthy as the current state store, so a tampered or rolled-back index would silently erase the evidence trail. + ## Algorithm ### Cascade Revocation @@ -29,15 +44,40 @@ The `parentDelegationId` field uses `string | null` (not optional). Root delegat When a delegation is revoked, all downstream delegations MUST be revoked synchronously. ``` -cascadeRevoke(delegationId, reason, revokerKey): +# Entry point. Allocates the transaction and emits the completion record. +revokeWithCascade(rootDelegationId, reasonCode, detail, revokerKey): + 1. transactionId = new UUID + 2. revoked = cascadeRevoke(rootDelegationId, reasonCode, detail, revokerKey, + transactionId, cascadeOrigin=null) + 3. If revoked is empty (the root was already revoked, so this call is an + idempotent no-op): return (revoked, null). Nothing was revoked, so there + is no transaction to close and NO CascadeCompletionRecord is emitted. + 4. After every RevocationRecord in `revoked` has durably persisted, emit a signed + CascadeCompletionRecord { + transactionId, rootDelegationId, + revokedBy=revokerKey, completedAt=now, + totalRevoked=len(revoked), # >= 1, since revoked is non-empty here + revokedDelegationIds=[r.delegationId for r in revoked] + } + 5. Return (revoked, CascadeCompletionRecord) + +cascadeRevoke(delegationId, reasonCode, detail, revokerKey, transactionId, cascadeOrigin): 1. Look up delegation in store by delegationId - 2. If already revoked, return (idempotent) - 3. Mark delegation as revoked (revoked=true, revokedAt, reason) - 4. Sign revocation record with revokerKey - 5. Get all child delegationIds from chain registry - 6. For each childId: recursively cascadeRevoke(childId, reason, revokerKey) - 7. Emit revocation event - 8. Return RevocationRecord with cascadeCount + 2. If already revoked, return [] (idempotent: no re-emit, no count change) + 3. Mark delegation as revoked (revoked=true, revokedAt=now) in state + 4. descendants = [] + For each childId in chainRegistry.children[delegationId]: + # descendants inherit reasonCode=parent_revoked and the same transactionId + origin + descendants += cascadeRevoke(childId, "parent_revoked", detail, revokerKey, + transactionId, cascadeOrigin = cascadeOrigin or delegationId) + 5. Sign RevocationRecord { + delegationId, revokedBy=revokerKey, revokedAt, + reasonCode, detail?, transactionId, + cascadeOrigin, # null on the root, root delegationId on descendants + cascadeCount=len(descendants), evidenceRef? + } with revokerKey + 6. Emit revocation event + 7. Return [thisRecord] + descendants ``` **Properties:** @@ -46,15 +86,33 @@ cascadeRevoke(delegationId, reason, revokerKey): - **Idempotent** — revoking an already-revoked delegation is a no-op. - **Total (Transactional)** — no partial cascade. Either all descendants are revoked and their revocation records durably persisted, or none are. Implementations MUST execute steps 3-7 for the entire cascade within a single atomic transaction or equivalent mechanism that guarantees all-or-nothing visibility. In systems without multi-record transactions, implementations MUST document that cascade is best-effort and may leave partial state on failure. +### Cascade Completion + +Mode (Total versus best-effort) states what an implementation promises. It does not state what a specific cascade did. To make that observable from the records alone, every cascade emits a signed `CascadeCompletionRecord` after its last descendant's `RevocationRecord` durably persists: + +- A validator that sees a `CascadeCompletionRecord` for `transactionId` T MAY treat the subtree rooted at `rootDelegationId` as fully closed. +- A validator that sees `RevocationRecord`s carrying `transactionId` T but no matching `CascadeCompletionRecord` MUST treat the cascade as in progress or failed, never as closed. + +This turns the failure case, a partially revoked subtree read as closed, into a detectable condition rather than an assumption. `totalRevoked` and `revokedDelegationIds` let a validator cross-check that the count and identity of persisted `RevocationRecord`s match what the completion record claims. + ### Batch Revocation by Agent Revoke all delegations granted TO a specific agent, with cascade. ``` -batchRevokeByAgent(agentId, reason, revokerKey): +batchRevokeByAgent(agentId, reasonCode, detail, revokerKey): 1. Find all delegations where delegateId == agentId - 2. For each: cascadeRevoke(delegation, reason, revokerKey) - 3. Return list of RevocationRecords + 2. results = [] + For each delegation: + # Each root is its own cascade transaction: revokeWithCascade allocates a + # transactionId and emits a CascadeCompletionRecord per root. A delegation + # already killed by an earlier root in this batch returns ([], null) and is + # skipped, so no empty completion record is emitted. + (records, completion) = revokeWithCascade(delegation.delegationId, + reasonCode, detail, revokerKey) + if records is non-empty: + results += [(records, completion)] + 3. Return results ``` Use case: an agent is compromised. The principal revokes everything granted to that agent. All sub-delegations also die. @@ -136,3 +194,7 @@ Cascade revocation depends on correct scope matching. All implementations MUST u - Universal wildcard: `*` covers everything - Prefix wildcard: `commerce:*` covers `commerce` and `commerce:checkout` - No reverse: `code:deploy` does NOT cover `code` + +## Conformance Vectors + +Canonical-JSON test vectors for these structures live in [spec/conformance/cascade-revocation/vectors.json](/spec/conformance/cascade-revocation/vectors.json). Each vector pairs a structured `object` with the exact canonical-JSON bytes a conforming serializer MUST produce (RFC 8785 JCS: object keys sorted by code unit, no insignificant whitespace, UTF-8, integers with no exponent or fraction), plus the `signingInput` bytes (the same object with `signature` omitted) that the Ed25519 signature is computed over. This lets TypeScript and Python implementations assert byte-parity on both the wire form and the signing preimage. The set covers a root `Delegation`, a root `RevocationRecord`, a cascade-derived `RevocationRecord` (`reasonCode: parent_revoked` with `cascadeOrigin`), and the matching `CascadeCompletionRecord`. diff --git a/spec/conformance/cascade-revocation/vectors.json b/spec/conformance/cascade-revocation/vectors.json new file mode 100644 index 0000000..0e861d8 --- /dev/null +++ b/spec/conformance/cascade-revocation/vectors.json @@ -0,0 +1,86 @@ +{ + "description": "Canonical-JSON conformance vectors for cascade-revocation structures. `object` is the structure; `canonical` is the exact RFC 8785 (JCS) serialization a conforming implementation MUST produce byte-for-byte; `signingInput` is the canonical serialization with `signature` omitted, i.e. the Ed25519 signing preimage. `schemaRef` names the $def in ../../schema/cascade-revocation.schema.json. Signature values are fixed placeholders; these vectors test serialization byte-parity, not signature verification.", + "canonicalization": "RFC 8785 JSON Canonicalization Scheme: object keys sorted by UTF-16 code unit, no insignificant whitespace, UTF-8 output, integers rendered with no exponent or fraction.", + "schema": "../../schema/cascade-revocation.schema.json", + "vectors": [ + { + "id": "delegation-root", + "schemaRef": "Delegation", + "description": "Root delegation, parentDelegationId null.", + "object": { + "delegationId": "11111111-1111-4111-8111-111111111111", + "delegatorId": "did:aip:agent:principal", + "delegateId": "did:aip:agent:worker-a", + "scope": [ + "commerce:checkout" + ], + "currency": "USD", + "spendLimit": 50000, + "maxDepth": 3, + "currentDepth": 0, + "expiresAt": "2026-12-31T23:59:59Z", + "createdAt": "2026-07-01T09:00:00Z", + "parentDelegationId": null, + "revoked": false, + "signature": "ed25519:cGxhY2Vob2xkZXItc2lnbmF0dXJlLWJ5dGVzLWZvci1jb25mb3JtYW5jZQ==" + }, + "canonical": "{\"createdAt\":\"2026-07-01T09:00:00Z\",\"currency\":\"USD\",\"currentDepth\":0,\"delegateId\":\"did:aip:agent:worker-a\",\"delegationId\":\"11111111-1111-4111-8111-111111111111\",\"delegatorId\":\"did:aip:agent:principal\",\"expiresAt\":\"2026-12-31T23:59:59Z\",\"maxDepth\":3,\"parentDelegationId\":null,\"revoked\":false,\"scope\":[\"commerce:checkout\"],\"signature\":\"ed25519:cGxhY2Vob2xkZXItc2lnbmF0dXJlLWJ5dGVzLWZvci1jb25mb3JtYW5jZQ==\",\"spendLimit\":50000}", + "signingInput": "{\"createdAt\":\"2026-07-01T09:00:00Z\",\"currency\":\"USD\",\"currentDepth\":0,\"delegateId\":\"did:aip:agent:worker-a\",\"delegationId\":\"11111111-1111-4111-8111-111111111111\",\"delegatorId\":\"did:aip:agent:principal\",\"expiresAt\":\"2026-12-31T23:59:59Z\",\"maxDepth\":3,\"parentDelegationId\":null,\"revoked\":false,\"scope\":[\"commerce:checkout\"],\"spendLimit\":50000}" + }, + { + "id": "revrec-root", + "schemaRef": "RevocationRecord", + "description": "Root revocation (reasonCode compromised, no cascadeOrigin, cascadeCount 2, evidenceRef set).", + "object": { + "delegationId": "11111111-1111-4111-8111-111111111111", + "revokedBy": "did:aip:key:z6MkRevoker1", + "revokedAt": "2026-07-03T18:00:00Z", + "reasonCode": "compromised", + "transactionId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "cascadeCount": 2, + "evidenceRef": "https://evidence.aip.io/incident/42", + "signature": "ed25519:cGxhY2Vob2xkZXItc2lnbmF0dXJlLWJ5dGVzLWZvci1jb25mb3JtYW5jZQ==" + }, + "canonical": "{\"cascadeCount\":2,\"delegationId\":\"11111111-1111-4111-8111-111111111111\",\"evidenceRef\":\"https://evidence.aip.io/incident/42\",\"reasonCode\":\"compromised\",\"revokedAt\":\"2026-07-03T18:00:00Z\",\"revokedBy\":\"did:aip:key:z6MkRevoker1\",\"signature\":\"ed25519:cGxhY2Vob2xkZXItc2lnbmF0dXJlLWJ5dGVzLWZvci1jb25mb3JtYW5jZQ==\",\"transactionId\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"}", + "signingInput": "{\"cascadeCount\":2,\"delegationId\":\"11111111-1111-4111-8111-111111111111\",\"evidenceRef\":\"https://evidence.aip.io/incident/42\",\"reasonCode\":\"compromised\",\"revokedAt\":\"2026-07-03T18:00:00Z\",\"revokedBy\":\"did:aip:key:z6MkRevoker1\",\"transactionId\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"}" + }, + { + "id": "revrec-cascade-leaf", + "schemaRef": "RevocationRecord", + "description": "Cascade-derived leaf revocation (reasonCode parent_revoked, cascadeOrigin = root, same transactionId).", + "object": { + "delegationId": "33333333-3333-4333-8333-333333333333", + "revokedBy": "did:aip:key:z6MkRevoker1", + "revokedAt": "2026-07-03T18:00:01Z", + "reasonCode": "parent_revoked", + "detail": "revoked as descendant of compromised root", + "cascadeOrigin": "11111111-1111-4111-8111-111111111111", + "transactionId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "cascadeCount": 0, + "signature": "ed25519:cGxhY2Vob2xkZXItc2lnbmF0dXJlLWJ5dGVzLWZvci1jb25mb3JtYW5jZQ==" + }, + "canonical": "{\"cascadeCount\":0,\"cascadeOrigin\":\"11111111-1111-4111-8111-111111111111\",\"delegationId\":\"33333333-3333-4333-8333-333333333333\",\"detail\":\"revoked as descendant of compromised root\",\"reasonCode\":\"parent_revoked\",\"revokedAt\":\"2026-07-03T18:00:01Z\",\"revokedBy\":\"did:aip:key:z6MkRevoker1\",\"signature\":\"ed25519:cGxhY2Vob2xkZXItc2lnbmF0dXJlLWJ5dGVzLWZvci1jb25mb3JtYW5jZQ==\",\"transactionId\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"}", + "signingInput": "{\"cascadeCount\":0,\"cascadeOrigin\":\"11111111-1111-4111-8111-111111111111\",\"delegationId\":\"33333333-3333-4333-8333-333333333333\",\"detail\":\"revoked as descendant of compromised root\",\"reasonCode\":\"parent_revoked\",\"revokedAt\":\"2026-07-03T18:00:01Z\",\"revokedBy\":\"did:aip:key:z6MkRevoker1\",\"transactionId\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"}" + }, + { + "id": "cascade-completion", + "schemaRef": "CascadeCompletionRecord", + "description": "Completion record closing the transaction (totalRevoked 3 == len(revokedDelegationIds)).", + "object": { + "transactionId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "rootDelegationId": "11111111-1111-4111-8111-111111111111", + "revokedBy": "did:aip:key:z6MkRevoker1", + "completedAt": "2026-07-03T18:00:02Z", + "totalRevoked": 3, + "revokedDelegationIds": [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", + "33333333-3333-4333-8333-333333333333" + ], + "signature": "ed25519:cGxhY2Vob2xkZXItc2lnbmF0dXJlLWJ5dGVzLWZvci1jb25mb3JtYW5jZQ==" + }, + "canonical": "{\"completedAt\":\"2026-07-03T18:00:02Z\",\"revokedBy\":\"did:aip:key:z6MkRevoker1\",\"revokedDelegationIds\":[\"11111111-1111-4111-8111-111111111111\",\"22222222-2222-4222-8222-222222222222\",\"33333333-3333-4333-8333-333333333333\"],\"rootDelegationId\":\"11111111-1111-4111-8111-111111111111\",\"signature\":\"ed25519:cGxhY2Vob2xkZXItc2lnbmF0dXJlLWJ5dGVzLWZvci1jb25mb3JtYW5jZQ==\",\"totalRevoked\":3,\"transactionId\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"}", + "signingInput": "{\"completedAt\":\"2026-07-03T18:00:02Z\",\"revokedBy\":\"did:aip:key:z6MkRevoker1\",\"revokedDelegationIds\":[\"11111111-1111-4111-8111-111111111111\",\"22222222-2222-4222-8222-222222222222\",\"33333333-3333-4333-8333-333333333333\"],\"rootDelegationId\":\"11111111-1111-4111-8111-111111111111\",\"totalRevoked\":3,\"transactionId\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"}" + } + ] +} diff --git a/spec/schema/cascade-revocation.schema.json b/spec/schema/cascade-revocation.schema.json index e34fd2f..0ae0522 100644 --- a/spec/schema/cascade-revocation.schema.json +++ b/spec/schema/cascade-revocation.schema.json @@ -88,8 +88,8 @@ }, "RevocationRecord": { "type": "object", - "description": "Record of a delegation revocation", - "required": ["delegationId", "revokedBy", "revokedAt", "reason", "cascadeCount", "signature"], + "description": "Signed evidence that one delegation was revoked. One record is emitted per revoked delegation. Records are immutable signed evidence: current validity is answerable from state (Delegation.revoked and ChainRegistry), never from these records alone.", + "required": ["delegationId", "revokedBy", "revokedAt", "reasonCode", "transactionId", "cascadeCount", "signature"], "additionalProperties": false, "properties": { "delegationId": { @@ -99,25 +99,96 @@ }, "revokedBy": { "type": "string", - "description": "Agent performing the revocation" + "description": "Authority reference (revoker key id or agent id) that performed the revocation. Corresponds to revokerKey in the cascadeRevoke algorithm." }, "revokedAt": { "type": "string", "format": "date-time", - "description": "ISO 8601 timestamp" + "description": "ISO 8601 revocation timestamp. Carried inside the signed envelope, so the timestamp is issuer-attested rather than observer-assigned." }, - "reason": { + "reasonCode": { "type": "string", - "description": "Revocation reason" + "enum": ["compromised", "policy_violation", "superseded", "expired", "key_rotation", "parent_revoked", "manual"], + "description": "Machine-readable revocation reason. 'parent_revoked' marks a cascade-derived record (revoked because an ancestor was revoked); such records MUST set cascadeOrigin." + }, + "detail": { + "type": "string", + "description": "Optional human-readable detail accompanying reasonCode. Freetext; MUST NOT be parsed for authorization decisions." + }, + "cascadeOrigin": { + "type": "string", + "format": "uuid", + "description": "For cascade-derived revocations, the delegationId of the root delegation whose revocation initiated this cascade. Absent on the root revocation record. Lets a validator reconstruct why a leaf died." + }, + "transactionId": { + "type": "string", + "format": "uuid", + "description": "Identifier shared by every RevocationRecord in one cascade operation and by the matching CascadeCompletionRecord. A single non-cascading revoke is a transaction of size one." + }, + "evidenceRef": { + "type": "string", + "description": "Optional pointer (URI or content hash) to external evidence supporting the revocation, for dispute and compliance workflows." }, "cascadeCount": { "type": "integer", "minimum": 0, - "description": "Number of downstream delegations also revoked" + "description": "Number of downstream delegations also revoked as a result of this revocation (0 for a leaf)." + }, + "signature": { + "type": "string", + "description": "Ed25519 signature by the revoker over the canonical-JSON serialization of this record with the signature field omitted." + } + }, + "allOf": [ + { + "if": { + "required": ["reasonCode"], + "properties": { "reasonCode": { "const": "parent_revoked" } } + }, + "then": { "required": ["cascadeOrigin"] } + } + ] + }, + "CascadeCompletionRecord": { + "type": "object", + "description": "Signed evidence that a cascade transaction closed. Emitted exactly once per transactionId, AFTER the last descendant's RevocationRecord has durably persisted. Its presence is what distinguishes 'subtree fully closed' from 'revocation in progress' using the signed records alone: RevocationRecords for a transactionId with no matching CascadeCompletionRecord MUST be read as in progress or failed, never as closed.", + "required": ["transactionId", "rootDelegationId", "revokedBy", "completedAt", "totalRevoked", "revokedDelegationIds", "signature"], + "additionalProperties": false, + "properties": { + "transactionId": { + "type": "string", + "format": "uuid", + "description": "Matches the transactionId carried on every RevocationRecord in this cascade." + }, + "rootDelegationId": { + "type": "string", + "format": "uuid", + "description": "delegationId of the root delegation whose revocation initiated the cascade. Equals cascadeOrigin on the descendant records." + }, + "revokedBy": { + "type": "string", + "description": "Authority reference that initiated and completed the cascade. Matches revokedBy on the root RevocationRecord." + }, + "completedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp taken after the last descendant's RevocationRecord persisted. Issuer-attested inside the signed envelope." + }, + "totalRevoked": { + "type": "integer", + "minimum": 1, + "description": "Total delegations revoked in this transaction (root plus all descendants). MUST equal the length of revokedDelegationIds." + }, + "revokedDelegationIds": { + "type": "array", + "items": { "type": "string", "format": "uuid" }, + "minItems": 1, + "uniqueItems": true, + "description": "Every delegationId revoked under this transactionId, including the root. Lets a validator verify the subtree is fully closed from the records alone." }, "signature": { "type": "string", - "description": "Ed25519 signature by revoker" + "description": "Ed25519 signature by the revoker over the canonical-JSON serialization of this record with the signature field omitted." } } },