@emilia-protocol/sdk 0.9.0 → 0.10.0

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.
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // -- Params -----------------------------------------------------------------
4
4
 
5
5
  export interface EPClientOptions {
6
- baseUrl: string;
6
+ baseUrl?: string;
7
7
  apiKey?: string;
8
8
  timeout?: number;
9
9
  retries?: number;
@@ -119,6 +119,155 @@ export interface CreateSuppressionParams {
119
119
  expires_at: string;
120
120
  }
121
121
 
122
+ // -- v1 Guard Params --------------------------------------------------------
123
+
124
+ export type GuardActionType =
125
+ | 'benefit_bank_account_change'
126
+ | 'benefit_address_change'
127
+ | 'caseworker_override'
128
+ | 'vendor_bank_account_change'
129
+ | 'beneficiary_creation'
130
+ | 'large_payment_release'
131
+ | 'ai_agent_payment_action';
132
+
133
+ export type GuardDecision = 'allow' | 'observe' | 'allow_with_signoff' | 'deny';
134
+ export type GuardEnforcementMode = 'observe' | 'warn' | 'enforce';
135
+
136
+ export interface GuardQuorumPolicy {
137
+ mode?: 'threshold' | 'all';
138
+ required: number;
139
+ approvers: Array<{ role: string; approver: string }>;
140
+ }
141
+
142
+ export interface CreateTrustReceiptParams {
143
+ organizationId?: string;
144
+ actionType: GuardActionType | string;
145
+ targetResourceId: string;
146
+ policyId?: string;
147
+ enforcementMode?: GuardEnforcementMode;
148
+ beforeState?: Record<string, unknown>;
149
+ afterState?: Record<string, unknown>;
150
+ targetChangedFields?: string[];
151
+ amount?: number;
152
+ currency?: string;
153
+ riskFlags?: string[];
154
+ actorRole?: string;
155
+ actorDepartment?: string;
156
+ businessHours?: boolean;
157
+ velocitySameActor24h?: number;
158
+ priorDenialsActor30d?: number;
159
+ priorChangesTarget30d?: number;
160
+ destinationAgeDays?: number;
161
+ quorumPolicy?: GuardQuorumPolicy;
162
+ metadata?: Record<string, unknown>;
163
+ }
164
+
165
+ export interface TrustReceipt {
166
+ receipt_id: string;
167
+ decision: GuardDecision;
168
+ observed_decision?: GuardDecision | null;
169
+ policy_id: string;
170
+ policy_hash: string;
171
+ action_hash: string;
172
+ before_state_hash?: string | null;
173
+ after_state_hash?: string | null;
174
+ nonce: string;
175
+ expires_at: string;
176
+ signoff_required: boolean;
177
+ signoff_request_id?: string | null;
178
+ risk_flags?: string[];
179
+ receipt_status: string;
180
+ enforcement_mode: string;
181
+ reasons?: string[];
182
+ canonical_action: Record<string, unknown>;
183
+ }
184
+
185
+ export interface TrustReceiptState {
186
+ receipt_id: string;
187
+ organization_id: string;
188
+ action_type: string;
189
+ decision: GuardDecision;
190
+ enforcement_mode: string;
191
+ policy_id: string;
192
+ policy_hash: string;
193
+ action_hash: string;
194
+ expires_at: string;
195
+ signoff_required: boolean;
196
+ receipt_status: string;
197
+ signoff_key_class?: string | null;
198
+ timeline_event_count: number;
199
+ }
200
+
201
+ export interface RequestSignoffParams {
202
+ receiptId: string;
203
+ approverId?: string;
204
+ expiresInMinutes?: number;
205
+ comment?: string;
206
+ }
207
+
208
+ export interface SignoffRequest {
209
+ signoff_id?: string;
210
+ receipt_id: string;
211
+ action_hash: string;
212
+ initiator_id: string;
213
+ approver_id?: string;
214
+ expires_at: string;
215
+ status: string;
216
+ quorum?: { mode: string; required: number; count: number };
217
+ signoffs?: Array<{ signoff_id: string; role?: string; approver_id: string }>;
218
+ }
219
+
220
+ export interface ConsumeTrustReceiptResult {
221
+ receipt_id: string;
222
+ status: string;
223
+ consumed_at: string;
224
+ consumed_by_system: string;
225
+ execution_reference_id?: string | null;
226
+ }
227
+
228
+ export interface ExecutionAttestation {
229
+ receipt_id: string;
230
+ status: string;
231
+ binding_status: string;
232
+ executed_action_hash: string;
233
+ approved_action_hash: string;
234
+ execution_integrity: Record<string, unknown>;
235
+ }
236
+
237
+ export interface TrustReceiptEvidence {
238
+ document: Record<string, unknown> | null;
239
+ public_key: string | null;
240
+ signed: boolean;
241
+ verify_with: string;
242
+ receipt_id: string;
243
+ organization_id: string;
244
+ issued_at: string;
245
+ expires_at: string;
246
+ schema_version: string;
247
+ [key: string]: unknown;
248
+ }
249
+
250
+ export interface RequireReceiptParams extends CreateTrustReceiptParams {
251
+ executingSystem: string;
252
+ executionReferenceId?: string;
253
+ approverId?: string;
254
+ signoffComment?: string;
255
+ signoffExpiresInMinutes?: number;
256
+ onSignoffRequired?: (ctx: { client: EPClient; receipt: TrustReceipt; signoff?: SignoffRequest }) => Promise<void | boolean | { approved?: boolean }>;
257
+ executedAction?: Record<string, unknown> | ((ctx: { receipt: TrustReceipt; result: unknown }) => Record<string, unknown>);
258
+ executionId?: string | ((result: unknown) => string | undefined);
259
+ fetchEvidence?: boolean;
260
+ }
261
+
262
+ export interface RequireReceiptResult<T> {
263
+ result: T;
264
+ receipt: TrustReceipt;
265
+ signoff?: SignoffRequest;
266
+ consume: ConsumeTrustReceiptResult;
267
+ execution: ExecutionAttestation;
268
+ evidence?: TrustReceiptEvidence;
269
+ }
270
+
122
271
  // -- Eye Responses ----------------------------------------------------------
123
272
 
124
273
  export interface ObservationResponse {
@@ -198,14 +347,21 @@ export interface SimulatePolicyParams {
198
347
 
199
348
  export interface RolloutPolicyParams {
200
349
  policyId: string;
201
- strategy: 'canary' | 'blue-green' | 'linear' | string;
202
- percentage?: number;
350
+ /** Policy version number to roll out (resolved against handshake_policies). */
351
+ version: number;
352
+ /** Target environment, e.g. "production" or "staging". */
353
+ environment: string;
354
+ strategy?: 'immediate' | 'canary';
355
+ /** Traffic percentage (1–99) for canary rollouts. */
356
+ canaryPct?: number;
203
357
  }
204
358
 
205
359
  export interface DiffPolicyVersionsParams {
206
360
  policyId: string;
207
- versionA: string;
208
- versionB: string;
361
+ /** First version number to compare. */
362
+ v1: number;
363
+ /** Second version number to compare. */
364
+ v2: number;
209
365
  }
210
366
 
211
367
  // -- Responses --------------------------------------------------------------
@@ -463,34 +619,72 @@ export interface PolicySimulationResult {
463
619
  }
464
620
 
465
621
  export interface PolicyRolloutResult {
466
- policyId: string;
622
+ rollout_id: string;
623
+ /** handshake_policies policy_id of the rolled-out version row. */
624
+ policy_id: string;
625
+ policy_key: string;
626
+ version: number;
627
+ environment: string;
467
628
  strategy: string;
468
- percentage: number;
469
629
  status: string;
470
- startedAt: string;
630
+ canary_pct: number | null;
631
+ initiated_at: string;
632
+ tenant_id: string;
471
633
  }
472
634
 
635
+ /**
636
+ * A policy version is a handshake_policies row. Versions sharing a policy_key
637
+ * form the version history of a single policy.
638
+ */
473
639
  export interface PolicyVersion {
474
- versionId: string;
475
- policyId: string;
640
+ policy_id: string;
641
+ policy_key: string;
476
642
  version: number;
477
- createdAt: string;
478
- createdBy: string;
479
- changelog?: string;
643
+ name: string;
644
+ mode: string;
645
+ status: string;
646
+ rules: Record<string, unknown>;
647
+ created_at: string;
648
+ updated_at: string;
480
649
  }
481
650
 
482
- export interface PolicyDiff {
483
- policyId: string;
484
- versionA: string;
485
- versionB: string;
486
- changes: PolicyChange[];
651
+ export interface PolicyVersionsResult {
652
+ policy_id: string;
653
+ policy_key: string;
654
+ versions: PolicyVersion[];
655
+ count: number;
656
+ tenant_id: string;
487
657
  }
488
658
 
659
+ /** A single classified field change between two policy versions' rules. */
489
660
  export interface PolicyChange {
490
- field: string;
491
- oldValue: unknown;
492
- newValue: unknown;
493
- type: 'added' | 'removed' | 'modified';
661
+ path: string;
662
+ before: unknown;
663
+ after: unknown;
664
+ risk: 'loosening' | 'tightening' | 'neutral';
665
+ rationale: string;
666
+ }
667
+
668
+ /** Semantic diff of v1.rules vs v2.rules. */
669
+ export interface PolicyRulesDiff {
670
+ changes: PolicyChange[];
671
+ risk: 'loosening' | 'tightening' | 'neutral';
672
+ summary: {
673
+ loosening: number;
674
+ tightening: number;
675
+ neutral: number;
676
+ };
677
+ }
678
+
679
+ export interface PolicyDiff {
680
+ policy_id: string;
681
+ policy_key: string;
682
+ /** The lower-numbered handshake_policies version row. */
683
+ v1: PolicyVersion;
684
+ /** The higher-numbered handshake_policies version row. */
685
+ v2: PolicyVersion;
686
+ diff: PolicyRulesDiff;
687
+ tenant_id: string;
494
688
  }
495
689
 
496
690
  // -- Error ------------------------------------------------------------------
@@ -594,25 +788,43 @@ export class EPCloudClient {
594
788
  }, true);
595
789
  }
596
790
 
597
- /** Begin a rollout of a policy using the specified strategy. */
598
- async rolloutPolicy(policyId: string, strategy: string, percentage?: number): Promise<PolicyRolloutResult> {
791
+ /**
792
+ * Roll out a specific policy version to an environment. The version is
793
+ * resolved against handshake_policies by the policy's policy_key. Immediate
794
+ * rollouts supersede the prior active rollout for that (policy_key,
795
+ * environment); canary rollouts coexist (canaryPct in 1–99).
796
+ */
797
+ async rolloutPolicy(
798
+ policyId: string,
799
+ version: number,
800
+ environment: string,
801
+ strategy: 'immediate' | 'canary' = 'immediate',
802
+ canaryPct?: number,
803
+ ): Promise<PolicyRolloutResult> {
599
804
  return this._request<PolicyRolloutResult>('POST', `/api/cloud/policies/${encodeURIComponent(policyId)}/rollout`, {
805
+ version,
806
+ environment,
600
807
  strategy,
601
- percentage,
808
+ ...(strategy === 'canary' && canaryPct !== undefined ? { canary_pct: canaryPct } : {}),
602
809
  }, true);
603
810
  }
604
811
 
605
- /** List all versions of a policy. */
606
- async getPolicyVersions(policyId: string): Promise<PolicyVersion[]> {
607
- return this._request<PolicyVersion[]>('GET', `/api/cloud/policies/${encodeURIComponent(policyId)}/versions`, undefined, true);
812
+ /**
813
+ * List all versions of a policy. Versions are the handshake_policies rows
814
+ * sharing the policy's policy_key, returned newest-first inside an envelope.
815
+ */
816
+ async getPolicyVersions(policyId: string): Promise<PolicyVersionsResult> {
817
+ return this._request<PolicyVersionsResult>('GET', `/api/cloud/policies/${encodeURIComponent(policyId)}/versions`, undefined, true);
608
818
  }
609
819
 
610
- /** Diff two versions of a policy to see what changed. */
611
- async diffPolicyVersions(policyId: string, versionA: string, versionB: string): Promise<PolicyDiff> {
612
- return this._request<PolicyDiff>('POST', `/api/cloud/policies/${encodeURIComponent(policyId)}/diff`, {
613
- versionA,
614
- versionB,
615
- }, true);
820
+ /**
821
+ * Diff two versions of a policy to see what changed between their rules.
822
+ * v1 and v2 are version numbers; the response includes both version rows and
823
+ * a semantic diff classified as loosening / tightening / neutral.
824
+ */
825
+ async diffPolicyVersions(policyId: string, v1: number, v2: number): Promise<PolicyDiff> {
826
+ const qs = this._toQs({ v1: String(v1), v2: String(v2) });
827
+ return this._request<PolicyDiff>('GET', `/api/cloud/policies/${encodeURIComponent(policyId)}/diff${qs}`, undefined, true);
616
828
  }
617
829
 
618
830
  /** @internal Build query string from SignoffFilters. */
@@ -646,6 +858,31 @@ export class EPCloudClient {
646
858
 
647
859
  // -- Client -----------------------------------------------------------------
648
860
 
861
+ function trustReceiptBody(params: CreateTrustReceiptParams): Record<string, unknown> {
862
+ return {
863
+ organization_id: params.organizationId,
864
+ action_type: params.actionType,
865
+ target_resource_id: params.targetResourceId,
866
+ policy_id: params.policyId,
867
+ enforcement_mode: params.enforcementMode,
868
+ before_state: params.beforeState,
869
+ after_state: params.afterState,
870
+ target_changed_fields: params.targetChangedFields,
871
+ amount: params.amount,
872
+ currency: params.currency,
873
+ risk_flags: params.riskFlags,
874
+ actor_role: params.actorRole,
875
+ actor_department: params.actorDepartment,
876
+ business_hours: params.businessHours,
877
+ velocity_same_actor_24h: params.velocitySameActor24h,
878
+ prior_denials_actor_30d: params.priorDenialsActor30d,
879
+ prior_changes_target_30d: params.priorChangesTarget30d,
880
+ destination_age_days: params.destinationAgeDays,
881
+ quorum_policy: params.quorumPolicy,
882
+ metadata: params.metadata,
883
+ };
884
+ }
885
+
649
886
  export class EPClient {
650
887
  private readonly baseUrl: string;
651
888
  private readonly apiKey: string;
@@ -655,9 +892,15 @@ export class EPClient {
655
892
  /** Cloud-specific endpoints (dashboards, analytics, audit, policy management). */
656
893
  public readonly cloud: EPCloudClient;
657
894
 
658
- constructor(options: EPClientOptions) {
659
- this.baseUrl = options.baseUrl.replace(/\/+$/, '');
660
- this.apiKey = options.apiKey ?? '';
895
+ constructor(options: EPClientOptions = {}) {
896
+ const env = typeof process !== 'undefined' && process.env ? process.env : {};
897
+ // Trim trailing slashes without a regex — avoids the polynomial-backtracking
898
+ // CodeQL flags on /\/+$/ for long all-slash inputs.
899
+ const rawBase = options.baseUrl ?? env.EP_BASE_URL ?? 'https://emiliaprotocol.ai';
900
+ let baseEnd = rawBase.length;
901
+ while (baseEnd > 0 && rawBase.charCodeAt(baseEnd - 1) === 47 /* '/' */) baseEnd--;
902
+ this.baseUrl = rawBase.slice(0, baseEnd);
903
+ this.apiKey = options.apiKey ?? env.EP_API_KEY ?? '';
661
904
  this.timeout = options.timeout ?? 10_000;
662
905
  this.retries = options.retries ?? 2;
663
906
 
@@ -674,7 +917,7 @@ export class EPClient {
674
917
  const url = `${this.baseUrl}${path}`;
675
918
  const headers: Record<string, string> = {
676
919
  'Content-Type': 'application/json',
677
- 'User-Agent': '@emilia-protocol/sdk/0.9.0',
920
+ 'User-Agent': '@emilia-protocol/sdk/0.10.0',
678
921
  };
679
922
  if (auth && this.apiKey) {
680
923
  headers['Authorization'] = `Bearer ${this.apiKey}`;
@@ -694,8 +937,18 @@ export class EPClient {
694
937
  const data: unknown = await res.json().catch(() => undefined);
695
938
  if (!res.ok) {
696
939
  const p = data as Record<string, unknown> | undefined;
697
- const msg = typeof p?.['error'] === 'string' ? p['error'] : `EP API error: ${res.status}`;
698
- const code = typeof p?.['code'] === 'string' ? p['code'] : undefined;
940
+ const msg = typeof p?.['error'] === 'string'
941
+ ? p['error']
942
+ : typeof p?.['detail'] === 'string'
943
+ ? p['detail']
944
+ : typeof p?.['title'] === 'string'
945
+ ? p['title']
946
+ : `EP API error: ${res.status}`;
947
+ const code = typeof p?.['code'] === 'string'
948
+ ? p['code']
949
+ : typeof p?.['type'] === 'string'
950
+ ? p['type'].split('/').pop()
951
+ : undefined;
699
952
  throw new EPError(msg, res.status, code);
700
953
  }
701
954
  return data as T;
@@ -798,6 +1051,153 @@ export class EPClient {
798
1051
  );
799
1052
  }
800
1053
 
1054
+ // ------------------------------------------------------------------
1055
+ // v1 Trust Receipt Enforcement
1056
+ // ------------------------------------------------------------------
1057
+
1058
+ /** Create a v1 pre-action trust receipt for a high-risk mutation. */
1059
+ async createTrustReceipt(params: CreateTrustReceiptParams): Promise<TrustReceipt> {
1060
+ return this.request<TrustReceipt>('POST', '/api/v1/trust-receipts', trustReceiptBody(params), true);
1061
+ }
1062
+
1063
+ /** Read current receipt state from the append-only v1 audit timeline. */
1064
+ async getTrustReceipt(receiptId: string): Promise<TrustReceiptState> {
1065
+ return this.request<TrustReceiptState>(
1066
+ 'GET',
1067
+ `/api/v1/trust-receipts/${encodeURIComponent(receiptId)}`,
1068
+ undefined,
1069
+ true,
1070
+ );
1071
+ }
1072
+
1073
+ /** Request human signoff for a receipt that requires approval. */
1074
+ async requestSignoff(params: RequestSignoffParams): Promise<SignoffRequest> {
1075
+ return this.request<SignoffRequest>('POST', '/api/v1/signoffs/request', {
1076
+ receipt_id: params.receiptId,
1077
+ approver_id: params.approverId,
1078
+ expires_in_minutes: params.expiresInMinutes,
1079
+ comment: params.comment,
1080
+ }, true);
1081
+ }
1082
+
1083
+ /** Consume a receipt before mutation. If this fails, do not execute the write. */
1084
+ async consumeTrustReceipt(
1085
+ receiptId: string,
1086
+ params: { actionHash: string; executingSystem: string; executionReferenceId?: string },
1087
+ ): Promise<ConsumeTrustReceiptResult> {
1088
+ return this.request<ConsumeTrustReceiptResult>(
1089
+ 'POST',
1090
+ `/api/v1/trust-receipts/${encodeURIComponent(receiptId)}/consume`,
1091
+ {
1092
+ action_hash: params.actionHash,
1093
+ executing_system: params.executingSystem,
1094
+ execution_reference_id: params.executionReferenceId,
1095
+ },
1096
+ true,
1097
+ );
1098
+ }
1099
+
1100
+ /** Emit the post-mutation execution attestation bound to the consumed receipt. */
1101
+ async attestExecution(
1102
+ receiptId: string,
1103
+ params: {
1104
+ executedAction: Record<string, unknown>;
1105
+ executingSystem: string;
1106
+ executionId?: string;
1107
+ executedAt?: string;
1108
+ },
1109
+ ): Promise<ExecutionAttestation> {
1110
+ return this.request<ExecutionAttestation>(
1111
+ 'POST',
1112
+ `/api/v1/trust-receipts/${encodeURIComponent(receiptId)}/execution`,
1113
+ {
1114
+ executed_action: params.executedAction,
1115
+ executing_system: params.executingSystem,
1116
+ execution_id: params.executionId,
1117
+ executed_at: params.executedAt,
1118
+ },
1119
+ true,
1120
+ );
1121
+ }
1122
+
1123
+ /** Fetch the signed evidence packet, when the receipt is in a signable state. */
1124
+ async getTrustReceiptEvidence(receiptId: string): Promise<TrustReceiptEvidence> {
1125
+ return this.request<TrustReceiptEvidence>(
1126
+ 'GET',
1127
+ `/api/v1/trust-receipts/${encodeURIComponent(receiptId)}/evidence`,
1128
+ undefined,
1129
+ true,
1130
+ );
1131
+ }
1132
+
1133
+ /**
1134
+ * Wrap a dangerous mutation in the v1 receipt lifecycle.
1135
+ * The mutation runs only after create + consume succeed.
1136
+ */
1137
+ async requireReceipt<T>(
1138
+ params: RequireReceiptParams,
1139
+ mutate: (ctx: { receipt: TrustReceipt; consume: ConsumeTrustReceiptResult }) => Promise<T>,
1140
+ ): Promise<RequireReceiptResult<T>> {
1141
+ const receipt = await this.createTrustReceipt(params);
1142
+ if (receipt.decision === 'deny' || receipt.receipt_status === 'denied') {
1143
+ throw new EPError('EMILIA denied the action before execution', 403, 'receipt_denied');
1144
+ }
1145
+
1146
+ let signoff: SignoffRequest | undefined;
1147
+ if (receipt.signoff_required) {
1148
+ if (!params.approverId && !params.quorumPolicy) {
1149
+ throw new EPError('Receipt requires signoff; pass approverId or quorumPolicy', 409, 'missing_approver_id');
1150
+ }
1151
+ signoff = await this.requestSignoff({
1152
+ receiptId: receipt.receipt_id,
1153
+ approverId: params.approverId,
1154
+ expiresInMinutes: params.signoffExpiresInMinutes,
1155
+ comment: params.signoffComment,
1156
+ });
1157
+ if (!params.onSignoffRequired) {
1158
+ throw new EPError('Receipt requires human signoff before the mutation can run', 409, 'signoff_required');
1159
+ }
1160
+ const signoffResult = await params.onSignoffRequired({ client: this, receipt, signoff });
1161
+ if (signoffResult === false || (typeof signoffResult === 'object' && signoffResult?.approved === false)) {
1162
+ throw new EPError('Human signoff was not approved', 403, 'signoff_rejected');
1163
+ }
1164
+ }
1165
+
1166
+ const consume = await this.consumeTrustReceipt(receipt.receipt_id, {
1167
+ actionHash: receipt.action_hash,
1168
+ executingSystem: params.executingSystem,
1169
+ executionReferenceId: params.executionReferenceId,
1170
+ });
1171
+ const result = await mutate({ receipt, consume });
1172
+ const executedAction = typeof params.executedAction === 'function'
1173
+ ? params.executedAction({ receipt, result })
1174
+ : params.executedAction ?? receipt.canonical_action;
1175
+ const executionId = typeof params.executionId === 'function'
1176
+ ? params.executionId(result)
1177
+ : params.executionId;
1178
+ const execution = await this.attestExecution(receipt.receipt_id, {
1179
+ executedAction,
1180
+ executingSystem: params.executingSystem,
1181
+ executionId,
1182
+ });
1183
+ const evidence = params.fetchEvidence
1184
+ ? await this.getTrustReceiptEvidence(receipt.receipt_id)
1185
+ : undefined;
1186
+
1187
+ return { result, receipt, signoff, consume, execution, evidence };
1188
+ }
1189
+
1190
+ /** Wrap an existing async function with requireReceipt(). */
1191
+ withReceipt<TArgs extends unknown[], TResult>(
1192
+ params: RequireReceiptParams | ((...args: TArgs) => RequireReceiptParams),
1193
+ mutate: (...args: TArgs) => Promise<TResult>,
1194
+ ): (...args: TArgs) => Promise<RequireReceiptResult<TResult>> {
1195
+ return async (...args: TArgs) => {
1196
+ const resolved = typeof params === 'function' ? params(...args) : params;
1197
+ return this.requireReceipt(resolved, () => mutate(...args));
1198
+ };
1199
+ }
1200
+
801
1201
  // ------------------------------------------------------------------
802
1202
  // Signoff extension
803
1203
  // ------------------------------------------------------------------