diff --git a/docs/proposals/cascade-revocation.mdx b/docs/proposals/cascade-revocation.mdx new file mode 100644 index 0000000..dbaf3b5 --- /dev/null +++ b/docs/proposals/cascade-revocation.mdx @@ -0,0 +1,200 @@ +--- +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 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 + +When a delegation is revoked, all downstream delegations MUST be revoked synchronously. + +``` +# 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: 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:** +- **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 (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, reasonCode, detail, revokerKey): + 1. Find all delegations where delegateId == agentId + 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. + +### Chain Validation + +Before any action is permitted, the entire delegation chain back to the principal MUST be validated. + +``` +validateChain(delegationIds[]): + 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 + 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 +``` + +## 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. 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 + +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` + +## 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 new file mode 100644 index 0000000..0ae0522 --- /dev/null +++ b/spec/schema/cascade-revocation.schema.json @@ -0,0 +1,242 @@ +{ + "$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" + }, + "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 smallest unit of currency (e.g. cents). If omitted, no spend constraint" + }, + "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": "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": { + "type": "string", + "format": "uuid", + "description": "Delegation being revoked" + }, + "revokedBy": { + "type": "string", + "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 revocation timestamp. Carried inside the signed envelope, so the timestamp is issuer-attested rather than observer-assigned." + }, + "reasonCode": { + "type": "string", + "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 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 the revoker over the canonical-JSON serialization of this record with the signature field omitted." + } + } + }, + "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)" + } + } + } + } +}