@atlasent/sdk 2.5.0 → 2.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/README.md CHANGED
@@ -42,6 +42,41 @@ client.deployGate({ agent?, action?, context? })
42
42
 
43
43
  `verifyPermit()` confirms a previously-issued permit server-side. Signed/offline permit artifacts never imply deployment authorization by themselves.
44
44
 
45
+ ## Decision replay
46
+
47
+ Re-evaluate a recorded decision against its originally-pinned policy bundle and engine version. **Side-effect-free**: no audit row is written, no permit is minted (ADR-016 `mode: "replay"` sentinel). Useful for compliance review, regression-testing bundle changes, and post-incident investigation.
48
+
49
+ Two surfaces exist; pick the one that matches your call site:
50
+
51
+ ```ts
52
+ // SDK-canonical (preferred for new code) — wire DECISION_CHANGED is normalized
53
+ // to POLICY_DRIFT; 409 replay_not_eligible returns ENGINE_DRIFT or BUNDLE_MISSING
54
+ // instead of throwing. You can always `switch` on the variance kind.
55
+ const r = await client.replay({ evaluationId: "dec_abc123" });
56
+ switch (r.varianceKind) {
57
+ case "NONE": /* replay agrees */ break;
58
+ case "POLICY_DRIFT": /* same envelope/bundle, different decision */ break;
59
+ case "ENVELOPE_DRIFT": /* recorded envelope no longer hashes */ break;
60
+ case "ENGINE_DRIFT": /* original engine retired beyond archival */ break;
61
+ case "BUNDLE_MISSING": /* original eval had no bundle pinned */ break;
62
+ case "CHAIN_TAMPER": /* audit-chain v5 detector tripped */ break;
63
+ }
64
+ ```
65
+
66
+ ```ts
67
+ // Raw-wire surface — variance values pass through verbatim
68
+ // (NONE / DECISION_CHANGED / ENVELOPE_DRIFT); 409 throws AtlaSentError
69
+ const result = await client.replayDecision("dec_abc123");
70
+ if (result.variance === "DECISION_CHANGED") {
71
+ console.warn(
72
+ `Decision ${result.decision_id} drifted: ` +
73
+ `${result.original_decision} → ${result.replay_decision}`,
74
+ );
75
+ }
76
+ ```
77
+
78
+ `/v1/decisions/:id/replay` is alpha per `atlasent-api/docs/STABLE_V2_PROMOTION.md` — wire shapes can shift without a deprecation cycle until it graduates to stable v1.
79
+
45
80
  ## CI deploy-gate pattern
46
81
 
47
82
  ```ts
package/dist/hono.cjs CHANGED
@@ -173,9 +173,8 @@ function normalizeEvaluateRequest(input) {
173
173
  action_type: legacy.action,
174
174
  actor_id: legacy.agent
175
175
  };
176
- if (legacy.context !== void 0) {
177
- normalized.context = legacy.context;
178
- }
176
+ if (legacy.context !== void 0) normalized.context = legacy.context;
177
+ if (legacy.explain !== void 0) normalized.explain = legacy.explain;
179
178
  return normalized;
180
179
  }
181
180
  return input;
@@ -347,6 +346,7 @@ var AtlaSentClient = class {
347
346
  actor_id: normalized.actor_id,
348
347
  context: normalized.context ?? {}
349
348
  };
349
+ if (normalized.explain !== void 0) body.explain = normalized.explain;
350
350
  const { body: wire, rateLimit } = await this.post(
351
351
  "/v1-evaluate",
352
352
  body
@@ -383,9 +383,211 @@ var AtlaSentClient = class {
383
383
  reason,
384
384
  auditHash: wire.audit_hash ?? "",
385
385
  timestamp: wire.timestamp ?? "",
386
+ rateLimit,
387
+ ...wire.risk_envelope && {
388
+ riskEnvelope: {
389
+ weightedScore: wire.risk_envelope.weighted_score,
390
+ engineDecision: wire.risk_envelope.engine_decision,
391
+ envelopeDecision: wire.risk_envelope.envelope_decision,
392
+ promoted: wire.risk_envelope.promoted,
393
+ hardBlocks: wire.risk_envelope.hard_blocks ?? [],
394
+ ...wire.risk_envelope.factors && { factors: wire.risk_envelope.factors }
395
+ }
396
+ }
397
+ };
398
+ }
399
+ /**
400
+ * Batch evaluate — send up to 100 decisions in a single round-trip.
401
+ *
402
+ * Wraps `POST /v1-evaluate-batch`. The server evaluates each item
403
+ * against the active policy bundle and returns results in the same
404
+ * order as the input. One rate-limit token is consumed for the
405
+ * whole batch, and one audit-chain entry lists every included
406
+ * decision id.
407
+ *
408
+ * A per-item policy `deny` is **not** thrown — it appears as
409
+ * `item.decision === "deny"` in the returned items. A whole-batch
410
+ * network error, 4xx, or 5xx throws {@link AtlaSentError}.
411
+ *
412
+ * Requires the `v2_batch` tenant feature flag to be enabled on the
413
+ * org (returns 404 when off). Requires scope `evaluate:write`.
414
+ *
415
+ * @param requests - 1–100 evaluate items.
416
+ * @param batchId - Optional caller-supplied UUID for idempotency.
417
+ * A retried call with the same `batchId` and identical items
418
+ * returns the cached response within 24 h (`replayed: true`).
419
+ */
420
+ async evaluateBatch(requests, batchId) {
421
+ if (!Array.isArray(requests) || requests.length === 0) {
422
+ throw new AtlaSentError(
423
+ "evaluateBatch: requests must be a non-empty array",
424
+ { code: "bad_request" }
425
+ );
426
+ }
427
+ if (requests.length > 100) {
428
+ throw new AtlaSentError(
429
+ `evaluateBatch: requests.length ${requests.length} exceeds the 100-item cap`,
430
+ { code: "bad_request" }
431
+ );
432
+ }
433
+ const wireItems = requests.map((r) => ({
434
+ action_type: r.action,
435
+ actor_id: r.agent,
436
+ context: r.context ?? {}
437
+ }));
438
+ const wireBody = { items: wireItems };
439
+ if (batchId) wireBody.batch_id = batchId;
440
+ const { body: wire, rateLimit } = await this.post(
441
+ "/v1-evaluate-batch",
442
+ wireBody
443
+ );
444
+ const items = (wire.items ?? []).map(
445
+ (item) => {
446
+ const rawDecision = typeof item.decision === "string" ? item.decision.toLowerCase() : void 0;
447
+ const decision = rawDecision === "allow" || rawDecision === "deny" || rawDecision === "hold" || rawDecision === "escalate" ? rawDecision : void 0;
448
+ return {
449
+ index: item.index,
450
+ ...decision !== void 0 ? { decision } : {},
451
+ ...item.decision_id ? { decisionId: item.decision_id } : {},
452
+ ...item.permit_token != null ? { permitToken: item.permit_token } : {},
453
+ ...item.reason != null ? { reason: item.reason } : {},
454
+ ...item.audit_entry_hash ? { auditHash: item.audit_entry_hash } : {},
455
+ ...item.timestamp ? { timestamp: item.timestamp } : {},
456
+ ...item.error ? { error: item.error } : {},
457
+ ...item.message ? { message: item.message } : {}
458
+ };
459
+ }
460
+ );
461
+ return {
462
+ batchId: wire.batch_id,
463
+ items,
464
+ partial: wire.partial ?? false,
465
+ ...wire.replayed ? { replayed: wire.replayed } : {},
386
466
  rateLimit
387
467
  };
388
468
  }
469
+ /**
470
+ * Subscribe to a live stream of decisions for this org.
471
+ *
472
+ * Wraps `GET /v1-decisions-stream`. The server emits one SSE frame
473
+ * per audit event and sends a heartbeat every 15 s. The session
474
+ * auto-closes after `maxSeconds` (default 30 min); reconnect with
475
+ * the last received `event.id` to resume without replaying history.
476
+ *
477
+ * ```ts
478
+ * const controller = new AbortController();
479
+ * for await (const event of client.subscribeDecisions({ signal: controller.signal })) {
480
+ * if (event.type === "heartbeat") continue;
481
+ * console.log(event.type, event.decision, event.actorId);
482
+ * if (event.type === "session_end") break; // reconnect
483
+ * }
484
+ * ```
485
+ *
486
+ * Requires scope `audit:read`. Requires the `v2_decisions_stream`
487
+ * tenant feature flag (returns 404 when off).
488
+ */
489
+ async *subscribeDecisions(opts = {}) {
490
+ const url = new URL(`${this.baseUrl}/v1-decisions-stream`);
491
+ if (opts.types?.length) url.searchParams.set("types", opts.types.join(","));
492
+ if (opts.actorId) url.searchParams.set("actor_id", opts.actorId);
493
+ if (opts.maxSeconds !== void 0) url.searchParams.set("max_seconds", String(opts.maxSeconds));
494
+ const headers = {
495
+ Accept: "text/event-stream",
496
+ Authorization: `Bearer ${this.apiKey}`,
497
+ "User-Agent": this.userAgent,
498
+ // ADR-025: declare the wire-protocol version we were built
499
+ // against. Runtime serves this version's response shape; older
500
+ // versions outside the compatibility window get 426.
501
+ "X-AtlaSent-Protocol-Version": "1"
502
+ };
503
+ if (opts.lastEventId) headers["Last-Event-ID"] = opts.lastEventId;
504
+ let response;
505
+ try {
506
+ response = await this.fetchImpl(url.toString(), {
507
+ method: "GET",
508
+ headers,
509
+ ...opts.signal ? { signal: opts.signal } : {}
510
+ });
511
+ } catch (err) {
512
+ if (err instanceof Error && err.name === "AbortError") return;
513
+ throw new AtlaSentError(
514
+ `Failed to connect to decisions stream: ${err instanceof Error ? err.message : String(err)}`,
515
+ { code: "network" }
516
+ );
517
+ }
518
+ if (!response.ok) {
519
+ const code = response.status === 401 ? "invalid_api_key" : "server_error";
520
+ throw new AtlaSentError(
521
+ `Decisions stream returned ${response.status}`,
522
+ { code, status: response.status }
523
+ );
524
+ }
525
+ if (!response.body) {
526
+ throw new AtlaSentError("Decisions stream response has no body", { code: "bad_response" });
527
+ }
528
+ const reader = response.body.getReader();
529
+ const decoder = new TextDecoder("utf-8");
530
+ let buf = "";
531
+ try {
532
+ while (true) {
533
+ let chunk;
534
+ try {
535
+ chunk = await reader.read();
536
+ } catch (err) {
537
+ if (err instanceof Error && err.name === "AbortError") return;
538
+ throw new AtlaSentError(
539
+ `Decisions stream read error: ${err instanceof Error ? err.message : String(err)}`,
540
+ { code: "network" }
541
+ );
542
+ }
543
+ if (chunk.done) break;
544
+ buf += decoder.decode(chunk.value, { stream: true });
545
+ const rawBlocks = buf.split("\n\n");
546
+ buf = rawBlocks.pop() ?? "";
547
+ for (const block of rawBlocks) {
548
+ if (!block.trim()) continue;
549
+ if (block.trimStart().startsWith(":")) {
550
+ yield { type: "heartbeat" };
551
+ continue;
552
+ }
553
+ let id;
554
+ let eventType = "audit_event";
555
+ let dataLine = "";
556
+ for (const line of block.split("\n")) {
557
+ if (line.startsWith("id:")) id = line.slice(3).trim();
558
+ else if (line.startsWith("event:")) eventType = line.slice(6).trim();
559
+ else if (line.startsWith("data:")) dataLine = line.slice(5).trim();
560
+ }
561
+ if (!dataLine) continue;
562
+ let parsed;
563
+ try {
564
+ parsed = JSON.parse(dataLine);
565
+ } catch {
566
+ continue;
567
+ }
568
+ if (eventType === "session_end") {
569
+ yield { ...id !== void 0 ? { id } : {}, type: "session_end", payload: parsed };
570
+ return;
571
+ }
572
+ const decision = typeof parsed.decision === "string" ? parsed.decision.toLowerCase() : void 0;
573
+ yield {
574
+ ...id !== void 0 ? { id } : {},
575
+ type: eventType,
576
+ ...decision ? { decision } : {},
577
+ ...typeof parsed.actor_id === "string" ? { actorId: parsed.actor_id } : {},
578
+ ...typeof parsed.resource_type === "string" ? { resourceType: parsed.resource_type } : {},
579
+ ...typeof parsed.resource_id === "string" ? { resourceId: parsed.resource_id } : {},
580
+ ...parsed.payload && typeof parsed.payload === "object" ? { payload: parsed.payload } : {},
581
+ ...typeof parsed.hash === "string" ? { hash: parsed.hash } : {},
582
+ ...typeof parsed.previous_hash === "string" ? { previousHash: parsed.previous_hash } : {},
583
+ ...typeof parsed.occurred_at === "string" ? { occurredAt: parsed.occurred_at } : {}
584
+ };
585
+ }
586
+ }
587
+ } finally {
588
+ reader.releaseLock();
589
+ }
590
+ }
389
591
  /**
390
592
  * Pre-flight evaluation that always returns the constraint trace.
391
593
  *
@@ -452,7 +654,17 @@ var AtlaSentClient = class {
452
654
  reason,
453
655
  auditHash: wire.audit_hash ?? "",
454
656
  timestamp: wire.timestamp ?? "",
455
- rateLimit
657
+ rateLimit,
658
+ ...wire.risk_envelope && {
659
+ riskEnvelope: {
660
+ weightedScore: wire.risk_envelope.weighted_score,
661
+ engineDecision: wire.risk_envelope.engine_decision,
662
+ envelopeDecision: wire.risk_envelope.envelope_decision,
663
+ promoted: wire.risk_envelope.promoted,
664
+ hardBlocks: wire.risk_envelope.hard_blocks ?? [],
665
+ ...wire.risk_envelope.factors && { factors: wire.risk_envelope.factors }
666
+ }
667
+ }
456
668
  };
457
669
  let constraintTrace = null;
458
670
  if (wire.constraint_trace !== void 0 && wire.constraint_trace !== null && typeof wire.constraint_trace === "object") {
@@ -501,6 +713,7 @@ var AtlaSentClient = class {
501
713
  outcome: wire.outcome ?? "",
502
714
  permitHash: wire.permit_hash ?? "",
503
715
  timestamp: wire.timestamp ?? "",
716
+ expiresAt: wire.expires_at ?? null,
504
717
  rateLimit
505
718
  };
506
719
  }
@@ -822,6 +1035,151 @@ var AtlaSentClient = class {
822
1035
  }
823
1036
  return { ...wire, rateLimit };
824
1037
  }
1038
+ /**
1039
+ * Re-evaluate a recorded decision against its originally-pinned policy
1040
+ * bundle and engine version, and report whether the result agrees with
1041
+ * what was recorded.
1042
+ *
1043
+ * Wraps `POST /v1-decisions-replay/:id/replay`. **Side-effect-free** — no
1044
+ * audit chain row is written and no permit is issued (per ADR-016).
1045
+ * Useful for compliance review, regression testing of bundle changes,
1046
+ * and post-incident investigation.
1047
+ *
1048
+ * Outcomes encoded in the response:
1049
+ * - `variance: "NONE"` — replay agrees with the original decision.
1050
+ * - `variance: "DECISION_CHANGED"` — same envelope, same bundle, different
1051
+ * decision. Almost always indicates non-determinism in a rule
1052
+ * (e.g. wall-clock comparison) and warrants investigation.
1053
+ * - `variance: "ENVELOPE_DRIFT"` — the recorded request envelope no longer
1054
+ * hashes to the recorded value. The replay short-circuits without
1055
+ * running the engine; `replay_decision` is absent. Treat as evidence
1056
+ * of substrate tamper or a recorder bug.
1057
+ *
1058
+ * Server-side 409 responses (replay refused because the engine version
1059
+ * does not accept replay, or because no bundle was pinned) surface as
1060
+ * `AtlaSentError` with `code: "replay_not_eligible"` — callers should
1061
+ * treat them as expected for old / un-pinned decisions, not as bugs.
1062
+ *
1063
+ * Requires the `evaluate:write` API key scope.
1064
+ *
1065
+ * @param decisionId The UUID of the recorded decision to replay.
1066
+ * Matches `execution_evaluations.request_id`.
1067
+ *
1068
+ * @example
1069
+ * ```ts
1070
+ * const result = await client.replayDecision("dec_abc123");
1071
+ * if (result.variance === "DECISION_CHANGED") {
1072
+ * console.warn(
1073
+ * `Decision ${result.decision_id} changed on replay: ` +
1074
+ * `${result.original_decision} → ${result.replay_decision}`,
1075
+ * );
1076
+ * }
1077
+ * ```
1078
+ */
1079
+ async replayDecision(decisionId) {
1080
+ if (typeof decisionId !== "string" || decisionId.length === 0) {
1081
+ throw new AtlaSentError("decisionId is required", {
1082
+ code: "bad_request"
1083
+ });
1084
+ }
1085
+ const path = `/v1-decisions-replay/${encodeURIComponent(decisionId)}/replay`;
1086
+ const { body: wire, rateLimit } = await this.post(
1087
+ path,
1088
+ {}
1089
+ );
1090
+ if (typeof wire.decision_id !== "string" || typeof wire.original_decision !== "string" || typeof wire.engine_version_kind !== "string" || typeof wire.accepts_replay !== "boolean" || typeof wire.variance !== "string" || typeof wire.envelope_verification !== "string" || typeof wire.replayed_at !== "string") {
1091
+ throw new AtlaSentError(
1092
+ "Malformed response from /v1-decisions-replay/:id/replay: missing required fields",
1093
+ { code: "bad_response" }
1094
+ );
1095
+ }
1096
+ return { ...wire, rateLimit };
1097
+ }
1098
+ /**
1099
+ * ADR-015 Phase C — SDK-canonical replay runtime.
1100
+ *
1101
+ * Re-evaluates a recorded decision against its originally-pinned policy
1102
+ * bundle and engine version via `POST /v1/decisions/:id/replay`.
1103
+ * Side-effect-free server-side: no audit chain row is written and no
1104
+ * permit is issued (ADR-016 `mode: "replay"` sentinel).
1105
+ *
1106
+ * Differences from {@link replayDecision} (the 2.7.0 raw-wire surface):
1107
+ *
1108
+ * | | `replayDecision()` | `replay()` |
1109
+ * | --- | --- | --- |
1110
+ * | Path | `/v1-decisions-replay/:id/replay` | `/v1/decisions/:id/replay` |
1111
+ * | Variance | raw wire (`DECISION_CHANGED`) | SDK-canonical (`POLICY_DRIFT`) |
1112
+ * | 409 handling | throws `AtlaSentError` | returns `ENGINE_DRIFT` / `BUNDLE_MISSING` |
1113
+ * | Input shape | `decisionId: string` | `{ evaluationId }` |
1114
+ *
1115
+ * **Never throws on `409 replay_not_eligible`** — instead returns a
1116
+ * `ReplayResponse` with `varianceKind: "ENGINE_DRIFT"` (engine retired
1117
+ * beyond archival window) or `"BUNDLE_MISSING"` (no bundle pinned on
1118
+ * the original evaluation). Callers can always `switch` on
1119
+ * `result.varianceKind` without a try/catch.
1120
+ *
1121
+ * Fix-forward note: this method was originally landed in PR #275 but
1122
+ * dropped from the squash merge. The TS types (`ReplayResponse`,
1123
+ * `ReplayRequest`) and CHANGELOG made it through; the method itself
1124
+ * did not. Restored here to match the Python {@link
1125
+ * AtlaSentClient}.replay() that landed in atlasent-sdk@2.6.0 (Python).
1126
+ */
1127
+ async replay(input) {
1128
+ if (!input || typeof input.evaluationId !== "string" || input.evaluationId.length === 0) {
1129
+ throw new AtlaSentError("evaluationId is required", {
1130
+ code: "bad_request"
1131
+ });
1132
+ }
1133
+ const path = `/v1/decisions/${encodeURIComponent(input.evaluationId)}/replay`;
1134
+ let wire;
1135
+ let rateLimit;
1136
+ try {
1137
+ const result = await this.post(path, {});
1138
+ wire = result.body;
1139
+ rateLimit = result.rateLimit;
1140
+ } catch (err) {
1141
+ if (err instanceof AtlaSentError && err.status === 409) {
1142
+ const msg = (err.message ?? "").toLowerCase();
1143
+ const varianceKind2 = msg.includes("bundle") ? "BUNDLE_MISSING" : "ENGINE_DRIFT";
1144
+ return {
1145
+ decisionId: input.evaluationId,
1146
+ varianceKind: varianceKind2,
1147
+ originalDecision: "deny",
1148
+ acceptsReplay: false,
1149
+ replayedAt: (/* @__PURE__ */ new Date()).toISOString(),
1150
+ rateLimit: null
1151
+ };
1152
+ }
1153
+ throw err;
1154
+ }
1155
+ const VARIANCE_MAP = {
1156
+ NONE: "NONE",
1157
+ DECISION_CHANGED: "POLICY_DRIFT",
1158
+ ENVELOPE_DRIFT: "ENVELOPE_DRIFT",
1159
+ CHAIN_TAMPER: "CHAIN_TAMPER",
1160
+ BUNDLE_MISSING: "BUNDLE_MISSING",
1161
+ ENGINE_DRIFT: "ENGINE_DRIFT"
1162
+ };
1163
+ const rawVariance = typeof wire.variance === "string" ? wire.variance : "";
1164
+ const varianceKind = VARIANCE_MAP[rawVariance] ?? "NONE";
1165
+ const replayDec = typeof wire.replay_decision === "string" ? wire.replay_decision.toLowerCase() : void 0;
1166
+ const originalDec = typeof wire.original_decision === "string" ? wire.original_decision.toLowerCase() : "deny";
1167
+ const response = {
1168
+ decisionId: typeof wire.decision_id === "string" ? wire.decision_id : input.evaluationId,
1169
+ varianceKind,
1170
+ originalDecision: originalDec,
1171
+ acceptsReplay: typeof wire.accepts_replay === "boolean" ? wire.accepts_replay : true,
1172
+ replayedAt: typeof wire.replayed_at === "string" ? wire.replayed_at : (/* @__PURE__ */ new Date()).toISOString(),
1173
+ rateLimit
1174
+ };
1175
+ if (typeof wire.original_deny_code === "string") response.originalDenyCode = wire.original_deny_code;
1176
+ if (replayDec !== void 0) response.replayedDecision = replayDec;
1177
+ if (typeof wire.replay_deny_code === "string") response.replayedDenyCode = wire.replay_deny_code;
1178
+ if (typeof wire.engine_version === "string") response.engineVersion = wire.engine_version;
1179
+ if (typeof wire.engine_version_kind === "string") response.engineVersionKind = wire.engine_version_kind;
1180
+ if (typeof wire.envelope_verification === "string") response.envelopeVerification = wire.envelope_verification;
1181
+ return response;
1182
+ }
825
1183
  /**
826
1184
  * Open a streaming evaluation session against `POST /v1-evaluate-stream`.
827
1185
  *
@@ -870,6 +1228,8 @@ var AtlaSentClient = class {
870
1228
  "Content-Type": "application/json",
871
1229
  Authorization: `Bearer ${this.apiKey}`,
872
1230
  "User-Agent": this.userAgent,
1231
+ // ADR-025: wire-protocol version declared on every request.
1232
+ "X-AtlaSent-Protocol-Version": "1",
873
1233
  "X-Request-ID": requestId
874
1234
  };
875
1235
  if (lastEventId !== void 0) {
@@ -957,7 +1317,9 @@ var AtlaSentClient = class {
957
1317
  Accept: "application/json",
958
1318
  Authorization: `Bearer ${this.apiKey}`,
959
1319
  "User-Agent": this.userAgent,
960
- "X-Request-ID": requestId
1320
+ "X-Request-ID": requestId,
1321
+ // ADR-025: wire-protocol version declared on every request.
1322
+ "X-AtlaSent-Protocol-Version": "1"
961
1323
  };
962
1324
  if (method === "POST") headers["Content-Type"] = "application/json";
963
1325
  const bodyStr = method === "POST" ? JSON.stringify(body) : void 0;
@@ -1573,6 +1935,69 @@ var AtlaSentClient = class {
1573
1935
  );
1574
1936
  return body;
1575
1937
  }
1938
+ // ── Constrained governance agents (read surface) ──────────────────────────
1939
+ //
1940
+ // Three GETs onto the v1-governance-agents edge function. Doctrine:
1941
+ // findings produced by these endpoints are advisory signal, never
1942
+ // authority. There is no `runGovernanceAgent` method on this client —
1943
+ // invocation belongs in CI (atlasent-action `governance-agents` mode),
1944
+ // not in application code.
1945
+ /**
1946
+ * List the advisory governance-agent registry for the calling org.
1947
+ *
1948
+ * Calls `GET /v1/governance/agents`. The registry is reference data
1949
+ * seeded at runtime-DB migration time; every row has
1950
+ * `authority_class = "advisory"` and `can_authorize = false` —
1951
+ * structural invariants enforced by the schema, not policy.
1952
+ */
1953
+ async listGovernanceAgents() {
1954
+ const { body } = await this.get(
1955
+ "/v1/governance/agents"
1956
+ );
1957
+ return [...body.agents ?? []];
1958
+ }
1959
+ /**
1960
+ * List advisory findings emitted against one governed change.
1961
+ *
1962
+ * Calls `GET /v1/governance/findings?change_id=…[&agent_slug=…]`.
1963
+ * Returns the typed-finding rows in `created_at DESC` order, including
1964
+ * `routed_gate_id` when the finding→gate trigger linked them. Findings
1965
+ * with `can_authorize === false` (always) are advisory; rendering them
1966
+ * never satisfies a gate.
1967
+ */
1968
+ async listGovernanceFindings(query) {
1969
+ if (!query?.change_id) {
1970
+ throw new AtlaSentError("change_id is required", { code: "bad_request" });
1971
+ }
1972
+ const params = new URLSearchParams({ change_id: query.change_id });
1973
+ if (query.agent_slug) params.set("agent_slug", query.agent_slug);
1974
+ const { body } = await this.get(
1975
+ "/v1/governance/findings",
1976
+ params
1977
+ );
1978
+ return [...body.findings ?? []];
1979
+ }
1980
+ /**
1981
+ * List agent run records against one governed change.
1982
+ *
1983
+ * Calls `GET /v1/governance/evaluations?change_id=…[&agent_slug=…]`.
1984
+ * Returns every persisted evaluation, including `failed` / `timeout`
1985
+ * runs and `completed` runs with zero findings — the latter is the
1986
+ * positive signal "the agent ran and found nothing", which the UI
1987
+ * surfaces as `clear`.
1988
+ */
1989
+ async listGovernanceEvaluations(query) {
1990
+ if (!query?.change_id) {
1991
+ throw new AtlaSentError("change_id is required", { code: "bad_request" });
1992
+ }
1993
+ const params = new URLSearchParams({ change_id: query.change_id });
1994
+ if (query.agent_slug) params.set("agent_slug", query.agent_slug);
1995
+ const { body } = await this.get(
1996
+ "/v1/governance/evaluations",
1997
+ params
1998
+ );
1999
+ return [...body.evaluations ?? []];
2000
+ }
1576
2001
  };
1577
2002
  function parseRateLimitHeaders(headers) {
1578
2003
  const rawLimit = headers.get("x-ratelimit-limit");
@@ -1874,6 +2299,7 @@ function getClient() {
1874
2299
  sharedClient = new AtlaSentClient(options);
1875
2300
  return sharedClient;
1876
2301
  }
2302
+ var ACTION_TYPE_RE = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
1877
2303
  function wireDecisionToDenied(serverDecision) {
1878
2304
  const lower = serverDecision.toLowerCase();
1879
2305
  if (lower === "hold" || lower === "escalate") return lower;
@@ -1912,6 +2338,12 @@ async function computeExecutionHash(payload) {
1912
2338
  }
1913
2339
  }
1914
2340
  async function protect(request) {
2341
+ if (!ACTION_TYPE_RE.test(request.action)) {
2342
+ throw new AtlaSentError(
2343
+ `action must be in dot-notation format (e.g. "production.deploy"). Got: ${JSON.stringify(request.action)}`,
2344
+ { code: "bad_request" }
2345
+ );
2346
+ }
1915
2347
  const client = getClient();
1916
2348
  const evaluation = await client.evaluate(request);
1917
2349
  if (evaluation.decision !== "allow") {
@@ -1922,12 +2354,13 @@ async function protect(request) {
1922
2354
  auditHash: evaluation.auditHash
1923
2355
  });
1924
2356
  }
1925
- const environment = request.context?.environment ?? (() => {
1926
- console.warn(
1927
- "[atlasent] environment not set on evaluate request \u2014 defaulting to 'production'. Set context.environment explicitly to suppress."
2357
+ const environment = request.context?.environment;
2358
+ if (!environment) {
2359
+ throw new AtlaSentError(
2360
+ 'context.environment is required. Pass the environment where this action executes (e.g. "production", "staging").',
2361
+ { code: "bad_request" }
1928
2362
  );
1929
- return "production";
1930
- })();
2363
+ }
1931
2364
  const evaluatePayload = {
1932
2365
  action_type: request.action,
1933
2366
  actor_id: request.agent,
@@ -1958,7 +2391,8 @@ async function protect(request) {
1958
2391
  permitHash: verification.permitHash,
1959
2392
  auditHash: evaluation.auditHash,
1960
2393
  reason: evaluation.reason,
1961
- timestamp: verification.timestamp
2394
+ timestamp: verification.timestamp,
2395
+ permitExpiresAt: verification.expiresAt ?? null
1962
2396
  };
1963
2397
  }
1964
2398