@kya-os/contracts 1.8.1 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ /**
3
+ * Interaction Report Request Contract
4
+ *
5
+ * The per-tool-call attestation body that identity-aware runtimes POST to
6
+ * KTA's `POST /api/v1/agents/{did}/interactions` endpoint (xmcp-i#413). KTA
7
+ * verifies the embedded JWS against the agent's registered public key and
8
+ * folds the outcome into the Bayesian reputation engine.
9
+ *
10
+ * WHY this lives in @kya-os/contracts: per the repo's API-parity rule, the
11
+ * wire shape of any cross-service POST must be a shared, Zod-validated
12
+ * contract — never inlined at the call site. The producer here is
13
+ * `@kya-os/compute`'s attestation reporter; the consumer is the KTA route.
14
+ *
15
+ * PARITY CAVEAT: KTA's receiving schema lives in a different repository
16
+ * (Know-That-Ai/know-that-ai → `src/app/api/v1/agents/[did]/interactions/`)
17
+ * and cannot be imported or validated from here. This schema mirrors the body
18
+ * shape already accepted in production (the existing attestation flow), and
19
+ * MUST be kept in lock-step with that route when either side changes.
20
+ *
21
+ * Related Spec: MCP-I §5 (Reputation)
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.InteractionReportRequestSchema = exports.InteractionContextSchema = exports.InteractionOutcomeSchema = exports.INTERACTION_TYPE_SCOPE_EXECUTED = void 0;
25
+ exports.validateInteractionReportRequest = validateInteractionReportRequest;
26
+ const zod_1 = require("zod");
27
+ /**
28
+ * Interaction classification. Open-ended on the wire (KTA may accept more
29
+ * types over time), but the per-tool-call attestation path emits exactly
30
+ * `"scope_executed"`. Modeled as a string with that documented default rather
31
+ * than a closed enum so adding a new KTA-side type does not require a
32
+ * contracts bump on the producer before the receiver is ready.
33
+ */
34
+ exports.INTERACTION_TYPE_SCOPE_EXECUTED = "scope_executed";
35
+ /**
36
+ * Coarse success/failure outcome KTA folds into the reputation score. The
37
+ * compute reporter collapses its finer 4-value status (success/error/denied/
38
+ * timeout) onto this two-value axis — anything that is not an unambiguous
39
+ * success counts as a failure for reputation purposes.
40
+ */
41
+ exports.InteractionOutcomeSchema = zod_1.z.enum(["success", "failure"]);
42
+ /**
43
+ * Evidentiary context carried alongside the signed attestation. `client_did`
44
+ * is optional because the agent may receive calls from un-attested clients
45
+ * during the rollout window; the attestation still has value without it.
46
+ */
47
+ exports.InteractionContextSchema = zod_1.z.object({
48
+ /** MCP session id from the originating tools/call. May be empty for headless flows. */
49
+ session_id: zod_1.z.string(),
50
+ /** Calling client's DID, when known. Omitted (not null) when un-attested. */
51
+ client_did: zod_1.z.string().optional(),
52
+ /** Compact JWS attestation of the tool call, signed by the agent's key. */
53
+ attestation_jws: zod_1.z.string(),
54
+ });
55
+ /**
56
+ * Interaction Report Request Schema
57
+ *
58
+ * Fire-and-forget body POSTed per tool call. Authenticated by the
59
+ * `x-api-key` header (not part of the body); idempotency is the caller's
60
+ * responsibility via `idempotency_key`.
61
+ */
62
+ exports.InteractionReportRequestSchema = zod_1.z.object({
63
+ /** Interaction classification — `"scope_executed"` for tool-call attestations. */
64
+ interaction_type: zod_1.z.string(),
65
+ /** Reputation-facing outcome. */
66
+ outcome: exports.InteractionOutcomeSchema,
67
+ /** Authorized scopes for the call, when a delegation context supplies them. */
68
+ scopes: zod_1.z.array(zod_1.z.string()).optional(),
69
+ /** ISO 8601 timestamp of the call, set when the attestation is signed. */
70
+ timestamp: zod_1.z.string(),
71
+ /** Evidentiary context including the signed attestation. */
72
+ context: exports.InteractionContextSchema,
73
+ /** Per-call key so a retried POST of the same call is deduped by KTA. */
74
+ idempotency_key: zod_1.z.string(),
75
+ });
76
+ /**
77
+ * Validate an interaction report request. Mirrors the `validate*` helpers in
78
+ * `./api.ts` so call sites can branch on `.success` without importing zod.
79
+ */
80
+ function validateInteractionReportRequest(data) {
81
+ return exports.InteractionReportRequestSchema.safeParse(data);
82
+ }
@@ -13,31 +13,34 @@
13
13
  */
14
14
  export interface DownstreamHeaders {
15
15
  /** DID of the verified agent */
16
- 'KYA-Agent-DID': string;
17
- /** Optional delegation ID */
18
- 'KYA-Delegation-Id'?: string;
16
+ 'KYA-OS-Agent-DID': string;
19
17
  /** Optional delegation chain (format: vc_id>del_id>...) */
20
- 'KYA-Delegation-Chain'?: string;
18
+ 'KYA-OS-Delegation-Chain'?: string;
19
+ /** Optional Layer 2 delegation proof (signed JWT) */
20
+ 'KYA-OS-Delegation-Proof'?: string;
21
+ /** Optional JWS-compact DelegationCredential VC */
22
+ 'KYA-OS-Delegation-Credential'?: string;
21
23
  /** Proof ID for audit trail */
22
24
  'KYA-Proof-Id': string;
23
25
  /** Optional CRISP spend info (JSON string: {unit, delta, remaining}) */
24
26
  'KYA-CRISP-Spend'?: string;
25
27
  /** Optional session ID */
26
- 'KYA-Session-Id'?: string;
28
+ 'KYA-OS-Session-Id'?: string;
27
29
  /** Optional scopes */
28
- 'KYA-Granted-Scopes'?: string;
30
+ 'KYA-OS-Granted-Scopes'?: string;
29
31
  }
30
32
  /**
31
33
  * Header names as constants for type safety
32
34
  */
33
35
  export declare const DOWNSTREAM_HEADER_NAMES: Readonly<{
34
- readonly AGENT_DID: "KYA-Agent-DID";
35
- readonly DELEGATION_ID: "KYA-Delegation-Id";
36
- readonly DELEGATION_CHAIN: "KYA-Delegation-Chain";
36
+ readonly AGENT_DID: "KYA-OS-Agent-DID";
37
+ readonly DELEGATION_CHAIN: "KYA-OS-Delegation-Chain";
38
+ readonly DELEGATION_PROOF: "KYA-OS-Delegation-Proof";
39
+ readonly DELEGATION_CREDENTIAL: "KYA-OS-Delegation-Credential";
37
40
  readonly PROOF_ID: "KYA-Proof-Id";
38
41
  readonly CRISP_SPEND: "KYA-CRISP-Spend";
39
- readonly SESSION_ID: "KYA-Session-Id";
40
- readonly SCOPES: "KYA-Granted-Scopes";
42
+ readonly SESSION_ID: "KYA-OS-Session-Id";
43
+ readonly SCOPES: "KYA-OS-Granted-Scopes";
41
44
  }>;
42
45
  /**
43
46
  * CRISP Spend Info
@@ -75,8 +78,9 @@ export declare function parseCrispSpend(headerValue: string): CrispSpendInfo | n
75
78
  export declare function createDownstreamHeaders(config: {
76
79
  agentDid: string;
77
80
  proofId: string;
78
- delegationId?: string;
79
81
  delegationChain?: string;
82
+ delegationProof?: string;
83
+ delegationCredential?: string;
80
84
  crispSpend?: CrispSpendInfo;
81
85
  sessionId?: string;
82
86
  scopes?: string[];
@@ -16,13 +16,14 @@ exports.createDownstreamHeaders = createDownstreamHeaders;
16
16
  * Header names as constants for type safety
17
17
  */
18
18
  exports.DOWNSTREAM_HEADER_NAMES = Object.freeze({
19
- AGENT_DID: 'KYA-Agent-DID',
20
- DELEGATION_ID: 'KYA-Delegation-Id',
21
- DELEGATION_CHAIN: 'KYA-Delegation-Chain',
19
+ AGENT_DID: 'KYA-OS-Agent-DID',
20
+ DELEGATION_CHAIN: 'KYA-OS-Delegation-Chain',
21
+ DELEGATION_PROOF: 'KYA-OS-Delegation-Proof',
22
+ DELEGATION_CREDENTIAL: 'KYA-OS-Delegation-Credential',
22
23
  PROOF_ID: 'KYA-Proof-Id',
23
24
  CRISP_SPEND: 'KYA-CRISP-Spend',
24
- SESSION_ID: 'KYA-Session-Id',
25
- SCOPES: 'KYA-Granted-Scopes',
25
+ SESSION_ID: 'KYA-OS-Session-Id',
26
+ SCOPES: 'KYA-OS-Granted-Scopes',
26
27
  });
27
28
  /**
28
29
  * Helper to serialize CRISP spend info to header value
@@ -59,23 +60,26 @@ function parseCrispSpend(headerValue) {
59
60
  */
60
61
  function createDownstreamHeaders(config) {
61
62
  const headers = {
62
- 'KYA-Agent-DID': config.agentDid,
63
+ 'KYA-OS-Agent-DID': config.agentDid,
63
64
  'KYA-Proof-Id': config.proofId,
64
65
  };
65
- if (config.delegationId) {
66
- headers['KYA-Delegation-Id'] = config.delegationId;
67
- }
68
66
  if (config.delegationChain) {
69
- headers['KYA-Delegation-Chain'] = config.delegationChain;
67
+ headers['KYA-OS-Delegation-Chain'] = config.delegationChain;
68
+ }
69
+ if (config.delegationProof) {
70
+ headers['KYA-OS-Delegation-Proof'] = config.delegationProof;
71
+ }
72
+ if (config.delegationCredential) {
73
+ headers['KYA-OS-Delegation-Credential'] = config.delegationCredential;
70
74
  }
71
75
  if (config.crispSpend) {
72
76
  headers['KYA-CRISP-Spend'] = serializeCrispSpend(config.crispSpend);
73
77
  }
74
78
  if (config.sessionId) {
75
- headers['KYA-Session-Id'] = config.sessionId;
79
+ headers['KYA-OS-Session-Id'] = config.sessionId;
76
80
  }
77
81
  if (config.scopes && config.scopes.length > 0) {
78
- headers['KYA-Granted-Scopes'] = config.scopes.join(',');
82
+ headers['KYA-OS-Granted-Scopes'] = config.scopes.join(',');
79
83
  }
80
84
  return headers;
81
85
  }
@@ -9,6 +9,7 @@ export declare const AgentContextSchema: z.ZodObject<{
9
9
  scopes: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
10
10
  session: z.ZodString;
11
11
  confidence: z.ZodLiteral<"verified">;
12
+ outcome: z.ZodOptional<z.ZodEnum<["allowed", "denied", "step_up_required", "needs_authorization"]>>;
12
13
  delegationRef: z.ZodOptional<z.ZodString>;
13
14
  registry: z.ZodString;
14
15
  verifiedAt: z.ZodNumber;
@@ -22,6 +23,7 @@ export declare const AgentContextSchema: z.ZodObject<{
22
23
  verifiedAt: number;
23
24
  subject?: string | undefined;
24
25
  delegationRef?: string | undefined;
26
+ outcome?: "allowed" | "denied" | "step_up_required" | "needs_authorization" | undefined;
25
27
  }, {
26
28
  did: string;
27
29
  kid: string;
@@ -32,6 +34,7 @@ export declare const AgentContextSchema: z.ZodObject<{
32
34
  scopes?: string[] | undefined;
33
35
  subject?: string | undefined;
34
36
  delegationRef?: string | undefined;
37
+ outcome?: "allowed" | "denied" | "step_up_required" | "needs_authorization" | undefined;
35
38
  }>;
36
39
  export declare const VerifierResultSchema: z.ZodObject<{
37
40
  success: z.ZodBoolean;
@@ -43,6 +46,7 @@ export declare const VerifierResultSchema: z.ZodObject<{
43
46
  scopes: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
44
47
  session: z.ZodString;
45
48
  confidence: z.ZodLiteral<"verified">;
49
+ outcome: z.ZodOptional<z.ZodEnum<["allowed", "denied", "step_up_required", "needs_authorization"]>>;
46
50
  delegationRef: z.ZodOptional<z.ZodString>;
47
51
  registry: z.ZodString;
48
52
  verifiedAt: z.ZodNumber;
@@ -56,6 +60,7 @@ export declare const VerifierResultSchema: z.ZodObject<{
56
60
  verifiedAt: number;
57
61
  subject?: string | undefined;
58
62
  delegationRef?: string | undefined;
63
+ outcome?: "allowed" | "denied" | "step_up_required" | "needs_authorization" | undefined;
59
64
  }, {
60
65
  did: string;
61
66
  kid: string;
@@ -66,6 +71,7 @@ export declare const VerifierResultSchema: z.ZodObject<{
66
71
  scopes?: string[] | undefined;
67
72
  subject?: string | undefined;
68
73
  delegationRef?: string | undefined;
74
+ outcome?: "allowed" | "denied" | "step_up_required" | "needs_authorization" | undefined;
69
75
  }>>;
70
76
  error: z.ZodOptional<z.ZodObject<{
71
77
  code: z.ZodString;
@@ -102,6 +108,7 @@ export declare const VerifierResultSchema: z.ZodObject<{
102
108
  verifiedAt: number;
103
109
  subject?: string | undefined;
104
110
  delegationRef?: string | undefined;
111
+ outcome?: "allowed" | "denied" | "step_up_required" | "needs_authorization" | undefined;
105
112
  } | undefined;
106
113
  }, {
107
114
  success: boolean;
@@ -122,6 +129,7 @@ export declare const VerifierResultSchema: z.ZodObject<{
122
129
  scopes?: string[] | undefined;
123
130
  subject?: string | undefined;
124
131
  delegationRef?: string | undefined;
132
+ outcome?: "allowed" | "denied" | "step_up_required" | "needs_authorization" | undefined;
125
133
  } | undefined;
126
134
  }>;
127
135
  export declare const StructuredErrorSchema: z.ZodObject<{
@@ -169,15 +177,16 @@ export type AgentContext = z.infer<typeof AgentContextSchema>;
169
177
  export type VerifierResult = z.infer<typeof VerifierResultSchema>;
170
178
  export type StructuredError = z.infer<typeof StructuredErrorSchema>;
171
179
  export declare const AGENT_HEADERS: {
172
- readonly DID: "KYA-Agent-DID";
173
- readonly KEY_ID: "KYA-Agent-KeyId";
174
- readonly SUBJECT: "KYA-Agent-Subject";
175
- readonly SCOPES: "KYA-Agent-Scopes";
176
- readonly SESSION: "KYA-Agent-Session";
177
- readonly CONFIDENCE: "KYA-Agent-Confidence";
178
- readonly DELEGATION_REF: "KYA-Delegation-Ref";
179
- readonly REGISTRY: "KYA-Agent-Registry";
180
- readonly VERIFIED_AT: "KYA-Verified-At";
180
+ readonly DID: "KYA-OS-Agent-DID";
181
+ readonly KEY_ID: "KYA-OS-Agent-KeyId";
182
+ readonly SUBJECT: "KYA-OS-Agent-Subject";
183
+ readonly SCOPES: "KYA-OS-Agent-Scopes";
184
+ readonly SESSION: "KYA-OS-Agent-Session";
185
+ readonly CONFIDENCE: "KYA-OS-Agent-Confidence";
186
+ readonly OUTCOME: "KYA-OS-Agent-Outcome";
187
+ readonly DELEGATION_REF: "KYA-OS-Delegation-Ref";
188
+ readonly REGISTRY: "KYA-OS-Agent-Registry";
189
+ readonly VERIFIED_AT: "KYA-OS-Verified-At";
181
190
  };
182
191
  export declare const VERIFIER_ERROR_CODES: {
183
192
  readonly PROOF_INVALID_TS: "XMCP_I_PROOF_INVALID_TS";
@@ -186,6 +195,7 @@ export declare const VERIFIER_ERROR_CODES: {
186
195
  readonly PROOF_SKEW_EXCEEDED: "XMCP_I_PROOF_SKEW_EXCEEDED";
187
196
  readonly SESSION_IDLE_EXPIRED: "XMCP_I_SESSION_IDLE_EXPIRED";
188
197
  readonly SERVER_TIME_INVALID: "XMCP_I_SERVER_TIME_INVALID";
198
+ readonly OUTCOME_NOT_ALLOWED: "XMCP_I_OUTCOME_NOT_ALLOWED";
189
199
  };
190
200
  export declare const ERROR_HTTP_STATUS: {
191
201
  readonly XMCP_I_EBADPROOF: 403;
@@ -202,4 +212,5 @@ export declare const ERROR_HTTP_STATUS: {
202
212
  readonly XMCP_I_PROOF_SKEW_EXCEEDED: 401;
203
213
  readonly XMCP_I_SESSION_IDLE_EXPIRED: 401;
204
214
  readonly XMCP_I_SERVER_TIME_INVALID: 500;
215
+ readonly XMCP_I_OUTCOME_NOT_ALLOWED: 403;
205
216
  };
package/dist/verifier.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ERROR_HTTP_STATUS = exports.VERIFIER_ERROR_CODES = exports.AGENT_HEADERS = exports.StructuredErrorSchema = exports.VerifierResultSchema = exports.AgentContextSchema = void 0;
4
4
  const zod_1 = require("zod");
5
+ const proof_js_1 = require("./proof.js");
5
6
  /**
6
7
  * Verifier middleware schemas and headers
7
8
  */
@@ -12,6 +13,13 @@ exports.AgentContextSchema = zod_1.z.object({
12
13
  scopes: zod_1.z.array(zod_1.z.string()).default([]),
13
14
  session: zod_1.z.string().min(1),
14
15
  confidence: zod_1.z.literal("verified"),
16
+ // Policy outcome the verified proof attests to (ratified DIF decision).
17
+ // `confidence: "verified"` means the proof is authentic; `outcome` is the
18
+ // separate authorization dimension. A PEP must gate on `outcome === "allowed"`
19
+ // — a verified `denied` / `step_up_required` / `needs_authorization` proof is
20
+ // authentic but does NOT authorize the call. Optional for backward
21
+ // compatibility; the verifier always populates it (legacy proofs → "allowed").
22
+ outcome: proof_js_1.ProofOutcomeSchema.optional(),
15
23
  delegationRef: zod_1.z.string().optional(),
16
24
  registry: zod_1.z.string().url(),
17
25
  verifiedAt: zod_1.z.number().int().positive(),
@@ -44,15 +52,16 @@ exports.StructuredErrorSchema = zod_1.z.object({
44
52
  });
45
53
  // Header constants (frozen names)
46
54
  exports.AGENT_HEADERS = {
47
- DID: "KYA-Agent-DID",
48
- KEY_ID: "KYA-Agent-KeyId",
49
- SUBJECT: "KYA-Agent-Subject",
50
- SCOPES: "KYA-Agent-Scopes",
51
- SESSION: "KYA-Agent-Session",
52
- CONFIDENCE: "KYA-Agent-Confidence",
53
- DELEGATION_REF: "KYA-Delegation-Ref",
54
- REGISTRY: "KYA-Agent-Registry",
55
- VERIFIED_AT: "KYA-Verified-At",
55
+ DID: "KYA-OS-Agent-DID",
56
+ KEY_ID: "KYA-OS-Agent-KeyId",
57
+ SUBJECT: "KYA-OS-Agent-Subject",
58
+ SCOPES: "KYA-OS-Agent-Scopes",
59
+ SESSION: "KYA-OS-Agent-Session",
60
+ CONFIDENCE: "KYA-OS-Agent-Confidence",
61
+ OUTCOME: "KYA-OS-Agent-Outcome",
62
+ DELEGATION_REF: "KYA-OS-Delegation-Ref",
63
+ REGISTRY: "KYA-OS-Agent-Registry",
64
+ VERIFIED_AT: "KYA-OS-Verified-At",
56
65
  };
57
66
  // Verifier-specific error codes
58
67
  exports.VERIFIER_ERROR_CODES = {
@@ -62,6 +71,7 @@ exports.VERIFIER_ERROR_CODES = {
62
71
  PROOF_SKEW_EXCEEDED: "XMCP_I_PROOF_SKEW_EXCEEDED",
63
72
  SESSION_IDLE_EXPIRED: "XMCP_I_SESSION_IDLE_EXPIRED",
64
73
  SERVER_TIME_INVALID: "XMCP_I_SERVER_TIME_INVALID",
74
+ OUTCOME_NOT_ALLOWED: "XMCP_I_OUTCOME_NOT_ALLOWED",
65
75
  };
66
76
  // HTTP status mappings
67
77
  exports.ERROR_HTTP_STATUS = {
@@ -80,4 +90,5 @@ exports.ERROR_HTTP_STATUS = {
80
90
  [exports.VERIFIER_ERROR_CODES.PROOF_SKEW_EXCEEDED]: 401,
81
91
  [exports.VERIFIER_ERROR_CODES.SESSION_IDLE_EXPIRED]: 401,
82
92
  [exports.VERIFIER_ERROR_CODES.SERVER_TIME_INVALID]: 500,
93
+ [exports.VERIFIER_ERROR_CODES.OUTCOME_NOT_ALLOWED]: 403,
83
94
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kya-os/contracts",
3
- "version": "1.8.1",
3
+ "version": "1.9.1",
4
4
  "description": "Shared contracts, types, and schemas for MCP-I framework",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -142,6 +142,7 @@
142
142
  },
143
143
  "files": [
144
144
  "dist",
145
+ "parity-vectors",
145
146
  "package.json",
146
147
  "README.md"
147
148
  ],
@@ -0,0 +1,265 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "name": "Delegation chain conformance vectors",
4
+ "description": "Cascading-revocation, chain-integrity, per-hop attenuation, and multi-hop delegation chains (E3.5 / issue #2904). `core` = full reference semantics (mcp-i-core cascading-revocation + delegation-graph, contracts areChildConstraintsValid). `edge` = reduced-profile kya-os-engine verdict: single-hop is evaluated; multi-hop fails closed to Block(PolicyDenied) unless a higher-priority reason (Revoked/Expired) fires first.",
5
+ "version": "1.0.0",
6
+ "vectors": [
7
+ {
8
+ "id": "cascade-revoke-ancestor-revokes-leaf",
9
+ "category": "cascading-revocation",
10
+ "profiles": ["core", "edge"],
11
+ "description": "Two-hop chain whose root delegation is revoked while the leaf's own credential is clean. Core: leaf invalid via ancestor cascade. Edge: the engine sees only the leaf's top-level credentialStatus (clean) and cannot observe the ancestor revocation, so it fails closed on the multi-hop chain (PolicyDenied) rather than over-permitting.",
12
+ "input": {
13
+ "chain": [
14
+ {
15
+ "id": "cr-root",
16
+ "parentId": null,
17
+ "issuerDid": "did:web:org.example",
18
+ "subjectDid": "did:web:agent.example",
19
+ "grantedScopes": ["read:*"],
20
+ "credentialStatusId": "https://status.example/revocation/v1#5"
21
+ },
22
+ {
23
+ "id": "cr-leaf",
24
+ "parentId": "cr-root",
25
+ "issuerDid": "did:web:agent.example",
26
+ "subjectDid": "did:key:zSubLeaf",
27
+ "grantedScopes": ["read:email"],
28
+ "credentialStatusId": "https://status.example/revocation/v1#9"
29
+ }
30
+ ],
31
+ "requestedScope": "read:email",
32
+ "revokedStatusIds": ["https://status.example/revocation/v1#5"],
33
+ "currentTime": 1735689600
34
+ },
35
+ "expected": {
36
+ "core": {
37
+ "valid": false,
38
+ "failureReason": "cascaded-revocation",
39
+ "revokedAncestor": "cr-root"
40
+ },
41
+ "edge": {
42
+ "mode": "fail-closed",
43
+ "decision": {
44
+ "kind": "Block",
45
+ "reason": {
46
+ "kind": "PolicyDenied",
47
+ "detail": "multi-hop delegation chain (len=2) not evaluated by reduced edge profile; Layer-2 policy authoritative"
48
+ }
49
+ }
50
+ }
51
+ },
52
+ "note": "The leaf's own credentialStatus (#9) is NOT revoked, so the engine's Stage 3 does not fire Block(Revoked); the multi-hop guard yields PolicyDenied. If the leaf's own status WERE revoked, Stage 3's Revoked (priority 0) would outrank PolicyDenied — covered by the cross-runtime matrix, not here."
53
+ },
54
+ {
55
+ "id": "chain-integrity-broken-issuer-linkage",
56
+ "category": "chain-integrity",
57
+ "profiles": ["core", "edge"],
58
+ "description": "Two-hop chain where the leaf's issuer is NOT the root's subject (forged intermediate). Core: invalid-chain via delegation-graph linkage check. Edge: fails closed on the multi-hop chain.",
59
+ "input": {
60
+ "chain": [
61
+ {
62
+ "id": "ci-root",
63
+ "parentId": null,
64
+ "issuerDid": "did:web:org.example",
65
+ "subjectDid": "did:web:agent.example",
66
+ "grantedScopes": ["read:*"]
67
+ },
68
+ {
69
+ "id": "ci-leaf",
70
+ "parentId": "ci-root",
71
+ "issuerDid": "did:web:evil.example",
72
+ "subjectDid": "did:key:zSubLeaf",
73
+ "grantedScopes": ["read:email"]
74
+ }
75
+ ],
76
+ "requestedScope": "read:email",
77
+ "currentTime": 1735689600
78
+ },
79
+ "expected": {
80
+ "core": {
81
+ "valid": false,
82
+ "failureReason": "invalid-chain"
83
+ },
84
+ "edge": {
85
+ "mode": "fail-closed",
86
+ "decision": {
87
+ "kind": "Block",
88
+ "reason": {
89
+ "kind": "PolicyDenied",
90
+ "detail": "multi-hop delegation chain (len=2) not evaluated by reduced edge profile; Layer-2 policy authoritative"
91
+ }
92
+ }
93
+ }
94
+ },
95
+ "note": "delegation-graph.validateChain requires child.issuerDid === parent.subjectDid (delegation-graph.ts:243). did:web:evil.example breaks the linkage. The flat engine carries no per-hop issuer/subject, so it cannot detect this — fail-closed is the safe reduced-profile behavior."
96
+ },
97
+ {
98
+ "id": "attenuation-child-exceeds-parent-scope",
99
+ "category": "per-hop-attenuation",
100
+ "profiles": ["core", "edge"],
101
+ "description": "Two-hop chain where the child grants MORE than the parent (parent read:* via prefix, child write:billing). Core: attenuation-violation via areChildConstraintsValid. Edge: fails closed. This is the canonical over-permit case the reduced profile closes.",
102
+ "input": {
103
+ "chain": [
104
+ {
105
+ "id": "at-root",
106
+ "parentId": null,
107
+ "issuerDid": "did:web:org.example",
108
+ "subjectDid": "did:web:agent.example",
109
+ "grantedScopes": ["read:*"],
110
+ "constraints": {
111
+ "crisp": {
112
+ "scopes": [{ "resource": "read:", "matcher": "prefix" }]
113
+ }
114
+ }
115
+ },
116
+ {
117
+ "id": "at-leaf",
118
+ "parentId": "at-root",
119
+ "issuerDid": "did:web:agent.example",
120
+ "subjectDid": "did:key:zSubLeaf",
121
+ "grantedScopes": ["write:billing"],
122
+ "constraints": {
123
+ "crisp": {
124
+ "scopes": [{ "resource": "write:billing", "matcher": "exact" }]
125
+ }
126
+ }
127
+ }
128
+ ],
129
+ "requestedScope": "write:billing",
130
+ "currentTime": 1735689600
131
+ },
132
+ "expected": {
133
+ "core": {
134
+ "valid": false,
135
+ "failureReason": "attenuation-violation"
136
+ },
137
+ "edge": {
138
+ "mode": "fail-closed",
139
+ "decision": {
140
+ "kind": "Block",
141
+ "reason": {
142
+ "kind": "PolicyDenied",
143
+ "detail": "multi-hop delegation chain (len=2) not evaluated by reduced edge profile; Layer-2 policy authoritative"
144
+ }
145
+ }
146
+ }
147
+ },
148
+ "note": "Today the flat engine would flatten grants to [read:*, write:billing] and scope_satisfies('write:billing', ...) === true → silent Permit. areChildConstraintsValid rejects the widening (write:billing does not start with read:). Fail-closed converts the silent over-permit into Block(PolicyDenied)."
149
+ },
150
+ {
151
+ "id": "multihop-valid-chain-still-fail-closed",
152
+ "category": "multi-hop-valid",
153
+ "profiles": ["core", "edge"],
154
+ "description": "A WELL-FORMED two-hop chain: valid linkage, no revocation, child scopes within parent, request within leaf grant. Core: valid. Edge: STILL fails closed — the reduced profile does not certify multi-hop even when the chain is valid; the over-permit risk is structural, not data-dependent.",
155
+ "input": {
156
+ "chain": [
157
+ {
158
+ "id": "hp-root",
159
+ "parentId": null,
160
+ "issuerDid": "did:web:org.example",
161
+ "subjectDid": "did:web:agent.example",
162
+ "grantedScopes": ["read:email"],
163
+ "credentialStatusId": "https://status.example/revocation/v1#20",
164
+ "constraints": {
165
+ "crisp": {
166
+ "scopes": [{ "resource": "read:email", "matcher": "exact" }]
167
+ }
168
+ }
169
+ },
170
+ {
171
+ "id": "hp-leaf",
172
+ "parentId": "hp-root",
173
+ "issuerDid": "did:web:agent.example",
174
+ "subjectDid": "did:key:zSubLeaf",
175
+ "grantedScopes": ["read:email"],
176
+ "credentialStatusId": "https://status.example/revocation/v1#21",
177
+ "constraints": {
178
+ "crisp": {
179
+ "scopes": [{ "resource": "read:email", "matcher": "exact" }]
180
+ }
181
+ }
182
+ }
183
+ ],
184
+ "requestedScope": "read:email",
185
+ "revokedStatusIds": [],
186
+ "currentTime": 1735689600
187
+ },
188
+ "expected": {
189
+ "core": {
190
+ "valid": true
191
+ },
192
+ "edge": {
193
+ "mode": "fail-closed",
194
+ "decision": {
195
+ "kind": "Block",
196
+ "reason": {
197
+ "kind": "PolicyDenied",
198
+ "detail": "multi-hop delegation chain (len=2) not evaluated by reduced edge profile; Layer-2 policy authoritative"
199
+ }
200
+ }
201
+ }
202
+ },
203
+ "note": "Deliberate negative-of-convenience: proves the edge profile fails closed even on a VALID multi-hop chain. This is the honest cost of the reduced profile — Layer-2 tenant policy must opt these back in. Prevents a future maintainer from 'optimizing' the guard to permit valid-looking chains."
204
+ },
205
+ {
206
+ "id": "singlehop-scope-permit",
207
+ "category": "scope-match",
208
+ "profiles": ["edge"],
209
+ "description": "Single-hop delegation whose granted scope covers the requested scope. Edge: evaluated → Permit (regression guard that the multi-hop guard does NOT change the len<=1 path).",
210
+ "input": {
211
+ "chain": [
212
+ {
213
+ "id": "sh-permit",
214
+ "parentId": null,
215
+ "issuerDid": "did:web:org.example",
216
+ "subjectDid": "did:web:agent.example",
217
+ "grantedScopes": ["read:email"]
218
+ }
219
+ ],
220
+ "requestedScope": "read:email",
221
+ "currentTime": 1735689600
222
+ },
223
+ "expected": {
224
+ "edge": {
225
+ "mode": "evaluated",
226
+ "decision": { "kind": "Permit" }
227
+ }
228
+ },
229
+ "note": "Single-hop is within the engine's competence: it flattens the one step's grants and matches requestedScope. No core counterpart — for single-hop scope the engine is the reference."
230
+ },
231
+ {
232
+ "id": "singlehop-scope-out-of-scope",
233
+ "category": "scope-match",
234
+ "profiles": ["edge"],
235
+ "description": "Single-hop delegation whose granted scope does NOT cover the requested scope. Edge: evaluated → Block(OutOfScope). Proves the guard leaves the genuine single-hop scope rejection intact.",
236
+ "input": {
237
+ "chain": [
238
+ {
239
+ "id": "sh-oos",
240
+ "parentId": null,
241
+ "issuerDid": "did:web:org.example",
242
+ "subjectDid": "did:web:agent.example",
243
+ "grantedScopes": ["read:email"]
244
+ }
245
+ ],
246
+ "requestedScope": "write:billing",
247
+ "currentTime": 1735689600
248
+ },
249
+ "expected": {
250
+ "edge": {
251
+ "mode": "evaluated",
252
+ "decision": {
253
+ "kind": "Block",
254
+ "reason": {
255
+ "kind": "OutOfScope",
256
+ "requested": "write:billing",
257
+ "granted": ["read:email"]
258
+ }
259
+ }
260
+ }
261
+ },
262
+ "note": "Single-hop OutOfScope stays a genuine evaluated rejection (not fail-closed). granted is the flattened single-step grant set."
263
+ }
264
+ ]
265
+ }