@fabricorg/policy-opa 0.1.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @fabricorg/policy-opa
2
2
 
3
+ ## 0.3.0 — 2026-05-17
4
+
5
+ ### Minor
6
+
7
+ - **Policy guidance support.** `OpaDecision` now accepts `guidance?: PolicyGuidance` and the evaluator maps it directly to `PolicyOutcome.guidance`. This makes OPA-backed policies render through the same audit/remediation UI contract as TypeScript code policies.
8
+ - **Engine metadata cleanup.** User-facing facts move out of `dispatchEvidence.engine.metadata` and into `PolicyOutcome.guidance`. Engine metadata remains focused on OPA provenance (`decisionPath`, `inputHash`, `decisionId`, `bundleRevision`, `conditionResults`, `obligations`, raw `provenance`).
9
+ - **Default-deny failure guidance.** OPA infrastructure failures still return `block` by default, now with generic corrective guidance telling operators to check OPA reachability, bundle serving, and decision shape.
10
+ - **Peer-deps:** `@fabricorg/platform ^0.4.0`.
11
+
12
+ ## 0.2.0 — 2026-05-16
13
+
14
+ ### Minor
15
+
16
+ - **Rich execution provenance.** Adds an `OpaPolicyExecutor` shape (`execute(path, input) → OpaPolicyExecution`) alongside the existing `OpaPolicyClient` (`evaluate(path, input)`). The factory detects either shape on `ctx.services[serviceKey]` and prefers `execute` when both are present. Lets adapter callers surface OPA's `decision_id`, `provenance.version`, and `provenance.bundles[*].revision` — fields the high-level `@open-policy-agent/opa` SDK discards.
17
+ - **`executorFromOpaHttp({ baseUrl, fetch?, headers?, provenance? })`** — official executor that POSTs to `{baseUrl}/v1/data/{path}?provenance=true`, parses the snake_case OPA response, and surfaces full provenance. Zero runtime deps; works on Node 20+ (uses `globalThis.fetch`), and on any runtime by passing a custom `fetch` implementation. Recommended for production deployments that need audit-grade evidence.
18
+ - **`executorFromOpaClient(client)`** — wraps the high-level SDK as an `OpaPolicyExecutor` for hosts that want to standardize on the executor interface even while accepting the limited provenance the SDK exposes.
19
+ - **`OpaPolicyBinding.bundleName?`** — optional selector for `provenance.bundles[name].revision`. Selection rules: explicit `bundleName` wins; otherwise a single-bundle response is used unambiguously; multiple bundles without a selector are recorded as `engine.metadata.bundleRevisionStatus: "ambiguous"` so audit dashboards can flag policies that need explicit selectors.
20
+ - **`engine.metadata` richer fields.** When the executor surfaces them, `dispatchEvidence.engine.metadata` now carries `decisionId`, `bundleName`, `bundleRevision`, `bundleRevisionStatus`, and the raw `provenance` block. `engine.version` is set from `provenance.version` when present, otherwise falls back to the `engineVersion` option.
21
+ - **`mapDecision` third argument.** `mapDecision: (decision, ctx, execution) => …` — the `execution` arg exposes `decisionId`, `bundleRevision`, `opaVersion`, and `provenance` so mappers can lift specific provenance fields into `outcome.metadata` if the host wants them queryable outside `dispatchEvidence`. Existing two-argument mappers from v0.1 continue to work (TypeScript permits implementations with fewer parameters).
22
+ - **Failure evidence now includes provenance** when available. A malformed-response failure under the executor path still records `decisionId` and `bundleRevision` in `engine.metadata` — useful for correlating "OPA returned something we couldn't parse" against OPA's own decision logs.
23
+ - **Tests:** 25 → 50. Added 25 covering: executor path with full provenance, single-bundle vs. multi-bundle vs. ambiguous selection, missing-provenance non-fatality, mapDecision third arg, service shape detection (executor preferred over client), bundle-selector unit tests, `executorFromOpaClient` wrapping, and `executorFromOpaHttp` URL construction / snake_case parsing / header forwarding / non-2xx handling.
24
+
25
+ ### Backwards compatibility
26
+
27
+ All v0.1 surface area is preserved. Hosts wiring `ctx.services.opa` as an `OpaPolicyClient` (with `evaluate`) continue to work without code changes; they simply don't get OPA-side provenance in `engine.metadata`. Upgrade path is: swap the client for `executorFromOpaHttp(...)` to gain `decisionId` and `bundleRevision`, and add `bundleName` to bindings that target a specific OPA bundle.
28
+
3
29
  ## 0.1.0 — 2026-05-16
4
30
 
5
31
  ### Initial release
package/README.md CHANGED
@@ -8,11 +8,16 @@ The platform stays zero-dependency and vendor-neutral; the OPA-specific glue liv
8
8
 
9
9
  ```bash
10
10
  pnpm add @fabricorg/platform @fabricorg/policy-opa
11
- # Bring your own OPA client. The high-level SDK works out of the box:
11
+ # Optionally the official SDK if you want the high-level client shape:
12
12
  pnpm add @open-policy-agent/opa
13
13
  ```
14
14
 
15
- `@fabricorg/policy-opa` has **zero runtime dependencies**. The OPA client is structurally typed (`{ evaluate(path, input): Promise<unknown> }`) so the official `@open-policy-agent/opa` SDK works, and so do hand-rolled wrappers that capture more provenance from the low-level API.
15
+ `@fabricorg/policy-opa` has **zero runtime dependencies**. Two wiring shapes are supported:
16
+
17
+ - **`OpaPolicyExecutor`** (recommended) — `execute(path, input) → OpaPolicyExecution`. Surfaces full OPA-side provenance (`decisionId`, `bundleRevision`, `opaVersion`, raw `provenance` block) under `engine.metadata`. The package ships `executorFromOpaHttp({ baseUrl, ... })` for this.
18
+ - **`OpaPolicyClient`** — `evaluate(path, input) → Promise<unknown>`. Structurally compatible with the high-level `@open-policy-agent/opa` SDK. Simpler to wire but the SDK discards `decisionId` and `provenance` from the response, so audit evidence is thinner.
19
+
20
+ If a service implements both methods, `execute` is preferred.
16
21
 
17
22
  ## Usage — the four pieces
18
23
 
@@ -20,7 +25,7 @@ A complete OPA-backed evaluator has four pieces, three of which the vertical own
20
25
 
21
26
  ### 1. The vertical policy manifest
22
27
 
23
- Keep Fabric policy IDs, versions, decision paths, and Rego package names in one place. Don't inline strings at call sites.
28
+ Keep Fabric policy IDs, versions, decision paths, Rego package names, and bundle selectors in one place. Don't inline strings at call sites.
24
29
 
25
30
  ```ts
26
31
  import type { OpaPolicyBinding } from "@fabricorg/policy-opa";
@@ -31,10 +36,13 @@ export const LENDING_POLICY_BINDINGS = {
31
36
  version: 1,
32
37
  decisionPath: "fabric/lending/tcpa/contact_window/decision",
33
38
  regoPackage: "fabric.lending.tcpa.contact_window",
39
+ bundleName: "lending",
34
40
  },
35
41
  } satisfies Record<string, OpaPolicyBinding>;
36
42
  ```
37
43
 
44
+ `bundleName` is optional. Set it when your OPA serves multiple bundles and you need audit evidence to record exactly which bundle's revision produced the decision. If OPA serves only one bundle, omit `bundleName` and the adapter uses that bundle's revision unambiguously.
45
+
38
46
  ### 2. The fact builder (TypeScript)
39
47
 
40
48
  Snapshot the facts OPA needs from your database. OPA never touches your DB directly — your TypeScript host owns reads, tenant scoping, and redaction.
@@ -70,14 +78,18 @@ decision := {
70
78
  "result": result,
71
79
  "reason": reason,
72
80
  "conditionResults": condition_results,
73
- "factsUsed": facts_used,
81
+ "guidance": {
82
+ "summary": reason,
83
+ "factsUsed": facts_used,
84
+ "correctiveActions": corrective_actions,
85
+ },
74
86
  }
75
87
 
76
88
  result := "block" if not consent_active
77
89
  result := "block" if not within_window
78
90
  default result := "pass"
79
91
 
80
- # ...rules computing reason, condition_results, facts_used...
92
+ # ...rules computing reason, condition_results, facts_used, corrective_actions...
81
93
  ```
82
94
 
83
95
  The validator accepts the following fields (TypeScript types: `OpaDecision`):
@@ -92,7 +104,7 @@ The validator accepts the following fields (TypeScript types: `OpaDecision`):
92
104
  result: "pass" | "warn" | "block";
93
105
  reason?: string;
94
106
  }>;
95
- factsUsed?: Record<string, unknown>;
107
+ guidance?: PolicyGuidance;
96
108
  obligations?: Record<string, unknown>;
97
109
  }
98
110
  ```
@@ -117,12 +129,19 @@ registerPolicy(tcpaPolicy);
117
129
 
118
130
  ### Host wiring
119
131
 
120
- The runtime host injects the OPA client into `PolicyContext.services` when it builds a policy context. With the high-level SDK:
132
+ The runtime host injects an OPA executor (or client) into `PolicyContext.services` when it builds a policy context.
133
+
134
+ **Recommended — HTTP executor with full provenance:**
121
135
 
122
136
  ```ts
123
- import { OPAClient } from "@open-policy-agent/opa";
137
+ import { executorFromOpaHttp } from "@fabricorg/policy-opa";
124
138
 
125
- const opa = new OPAClient(process.env.OPA_URL ?? "http://localhost:8181");
139
+ const opa = executorFromOpaHttp({
140
+ baseUrl: process.env.OPA_URL ?? "http://localhost:8181",
141
+ // provenance: true is the default — `?provenance=true` is appended to
142
+ // every request so OPA returns decision_id and bundle revisions.
143
+ // headers: { authorization: `Bearer ${process.env.OPA_TOKEN}` },
144
+ });
126
145
 
127
146
  // when constructing PolicyContext for evaluatePolicyDefinitions:
128
147
  const policyCtx = {
@@ -133,9 +152,21 @@ const policyCtx = {
133
152
  };
134
153
  ```
135
154
 
155
+ **Alternative — wrap the high-level SDK:**
156
+
157
+ ```ts
158
+ import { OPAClient } from "@open-policy-agent/opa";
159
+ import { executorFromOpaClient } from "@fabricorg/policy-opa";
160
+
161
+ const opa = executorFromOpaClient(new OPAClient(process.env.OPA_URL ?? "http://localhost:8181"));
162
+ // ... services: { opa }
163
+ ```
164
+
165
+ This works (the adapter accepts an `OpaPolicyClient` directly too, so wrapping isn't strictly required), but the high-level SDK discards `decision_id` and `provenance` — audit evidence is thinner than with `executorFromOpaHttp`.
166
+
136
167
  ## What lands in audit evidence
137
168
 
138
- For a successful OPA evaluation, `PolicyDispatchEvidence.engine` looks like:
169
+ For a successful OPA evaluation under the HTTP executor (with provenance enabled), `PolicyDispatchEvidence.engine` looks like:
139
170
 
140
171
  ```json
141
172
  {
@@ -145,13 +176,21 @@ For a successful OPA evaluation, `PolicyDispatchEvidence.engine` looks like:
145
176
  "decisionPath": "fabric/lending/tcpa/contact_window/decision",
146
177
  "regoPackage": "fabric.lending.tcpa.contact_window",
147
178
  "inputHash": "sha256:8a3c…",
148
- "conditionResults": [{ "conditionId": "consent_active", "passed": true, "result": "pass" }],
149
- "factsUsed": { "consent": { "active": true } }
179
+ "decisionId": "01J0Q9MZ…",
180
+ "bundleName": "lending",
181
+ "bundleRevision": "2026-05-16.3",
182
+ "provenance": {
183
+ "version": "0.65.0",
184
+ "bundles": { "lending": { "revision": "2026-05-16.3" } }
185
+ },
186
+ "conditionResults": [{ "conditionId": "consent_active", "passed": true, "result": "pass" }]
150
187
  }
151
188
  }
152
189
  ```
153
190
 
154
- Fabric's `policyId` / `policyVersion` remain canonical (`"what was evaluated"`). `engine.metadata` answers `"which artifact produced the answer"`. Both ride on the `PolicyEvaluation` row Fabric persists per outcome.
191
+ Fabric's `policyId` / `policyVersion` remain canonical (`"what was evaluated"`). `engine.metadata.{decisionId, bundleName, bundleRevision}` answers `"which artifact produced the answer"`, and `decisionId` is the join key against OPA's own decision-log stream if you ship it to a SIEM. User-facing facts and remediation steps live on `PolicyOutcome.guidance`, not in engine metadata, so code policies and OPA policies render through the same audit UI contract.
192
+
193
+ If OPA serves multiple bundles and the binding has no `bundleName` selector, `bundleRevision` is omitted and `bundleRevisionStatus: "ambiguous"` is recorded — audit dashboards can flag these for a manual fix.
155
194
 
156
195
  ## Failure modes
157
196
 
@@ -171,9 +210,9 @@ OPA evaluation is a network call, so the adapter defaults `previewSafe: false`.
171
210
  ## What this package does NOT do
172
211
 
173
212
  - It doesn't talk to your database. `buildInput` is yours.
174
- - It doesn't run an OPA server. You bring an OPA sidecar (or remote service) and inject a client.
213
+ - It doesn't run an OPA server. You bring an OPA sidecar (or remote service) and inject an executor or client.
175
214
  - It doesn't ship Rego policies. You author and bundle those.
176
- - It doesn't (yet) capture `bundleRevision` / `decisionId`. The high-level OPA SDK discards those fields; capturing them requires using the SDK's low-level `OpaApiClient` directly. A future minor release will add a richer client interface for hosts that want that provenance.
215
+ - It doesn't auto-discover bundle names. Bind one explicitly with `binding.bundleName` if OPA serves multiple bundles, or accept the `"ambiguous"` status in evidence.
177
216
 
178
217
  ## License
179
218
 
package/dist/index.cjs CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  var crypto = require('crypto');
4
4
 
5
+ // src/bundle-selector.ts
6
+ function selectBundleRevision(execution, binding) {
7
+ if (typeof execution.bundleRevision === "string" && execution.bundleRevision.length > 0) {
8
+ return { bundleRevision: execution.bundleRevision };
9
+ }
10
+ const bundles = readBundles(execution.provenance);
11
+ if (!bundles) return {};
12
+ const names = Object.keys(bundles);
13
+ if (names.length === 0) return {};
14
+ if (binding.bundleName) {
15
+ const revision = bundles[binding.bundleName]?.revision;
16
+ return typeof revision === "string" && revision.length > 0 ? { bundleRevision: revision } : {};
17
+ }
18
+ if (names.length === 1) {
19
+ const only = names[0];
20
+ const revision = only !== void 0 ? bundles[only]?.revision : void 0;
21
+ return typeof revision === "string" && revision.length > 0 ? { bundleRevision: revision } : {};
22
+ }
23
+ return { bundleRevisionStatus: "ambiguous" };
24
+ }
25
+ function readBundles(provenance) {
26
+ if (!provenance || typeof provenance !== "object") return void 0;
27
+ const b = provenance.bundles;
28
+ if (!b || typeof b !== "object" || Array.isArray(b)) return void 0;
29
+ return b;
30
+ }
31
+
5
32
  // src/decision-schema.ts
6
33
  var POLICY_RESULTS = ["pass", "warn", "block"];
7
34
  function parseOpaDecision(raw) {
@@ -27,8 +54,9 @@ function parseOpaDecision(raw) {
27
54
  if (err) return { ok: false, error: err };
28
55
  }
29
56
  }
30
- if (d.factsUsed !== void 0 && !isPlainObject(d.factsUsed)) {
31
- return { ok: false, error: "factsUsed must be an object when present" };
57
+ if (d.guidance !== void 0) {
58
+ const err = validateGuidance(d.guidance);
59
+ if (err) return { ok: false, error: err };
32
60
  }
33
61
  if (d.obligations !== void 0 && !isPlainObject(d.obligations)) {
34
62
  return { ok: false, error: "obligations must be an object when present" };
@@ -54,6 +82,68 @@ function validateConditionResult(value, index) {
54
82
  }
55
83
  return null;
56
84
  }
85
+ function validateGuidance(value) {
86
+ if (!isPlainObject(value)) {
87
+ return "guidance must be an object when present";
88
+ }
89
+ const guidance = value;
90
+ if (guidance.summary !== void 0 && typeof guidance.summary !== "string") {
91
+ return "guidance.summary must be a string when present";
92
+ }
93
+ if (!Array.isArray(guidance.factsUsed)) {
94
+ return "guidance.factsUsed must be an array";
95
+ }
96
+ for (let i = 0; i < guidance.factsUsed.length; i += 1) {
97
+ const err = validatePolicyFact(guidance.factsUsed[i], i);
98
+ if (err) return err;
99
+ }
100
+ if (!Array.isArray(guidance.correctiveActions)) {
101
+ return "guidance.correctiveActions must be an array";
102
+ }
103
+ for (let i = 0; i < guidance.correctiveActions.length; i += 1) {
104
+ const err = validateCorrectiveAction(guidance.correctiveActions[i], i);
105
+ if (err) return err;
106
+ }
107
+ return null;
108
+ }
109
+ function validatePolicyFact(value, index) {
110
+ if (!isPlainObject(value)) {
111
+ return `guidance.factsUsed[${index}] must be an object`;
112
+ }
113
+ const fact = value;
114
+ if (typeof fact.name !== "string" || fact.name.length === 0) {
115
+ return `guidance.factsUsed[${index}].name must be a non-empty string`;
116
+ }
117
+ if (fact.label !== void 0 && typeof fact.label !== "string") {
118
+ return `guidance.factsUsed[${index}].label must be a string when present`;
119
+ }
120
+ return null;
121
+ }
122
+ function validateCorrectiveAction(value, index) {
123
+ if (!isPlainObject(value)) {
124
+ return `guidance.correctiveActions[${index}] must be an object`;
125
+ }
126
+ const action = value;
127
+ if (typeof action.label !== "string" || action.label.length === 0) {
128
+ return `guidance.correctiveActions[${index}].label must be a non-empty string`;
129
+ }
130
+ if (action.description !== void 0 && typeof action.description !== "string") {
131
+ return `guidance.correctiveActions[${index}].description must be a string when present`;
132
+ }
133
+ if (action.actionId !== void 0 && typeof action.actionId !== "string") {
134
+ return `guidance.correctiveActions[${index}].actionId must be a string when present`;
135
+ }
136
+ if (action.parameters !== void 0 && !isPlainObject(action.parameters)) {
137
+ return `guidance.correctiveActions[${index}].parameters must be an object when present`;
138
+ }
139
+ if (action.target !== void 0 && !isPlainObject(action.target)) {
140
+ return `guidance.correctiveActions[${index}].target must be an object when present`;
141
+ }
142
+ if (action.requiresConfirmation !== void 0 && typeof action.requiresConfirmation !== "boolean") {
143
+ return `guidance.correctiveActions[${index}].requiresConfirmation must be a boolean when present`;
144
+ }
145
+ return null;
146
+ }
57
147
  function isPlainObject(value) {
58
148
  return value !== null && typeof value === "object" && !Array.isArray(value);
59
149
  }
@@ -91,13 +181,14 @@ function createOpaPolicyEvaluator(options) {
91
181
  version: binding.version,
92
182
  previewSafe,
93
183
  evaluate: async (ctx) => {
94
- const client = ctx.services?.[serviceKey];
95
- if (!client || typeof client.evaluate !== "function") {
184
+ const service = ctx.services?.[serviceKey];
185
+ const transport = resolveTransport(service);
186
+ if (!transport) {
96
187
  return makeFailureOutcome(
97
188
  binding,
98
189
  engineVersion,
99
190
  "client_unavailable",
100
- `No OPA client at ctx.services["${serviceKey}"]`,
191
+ `No OPA client/executor at ctx.services["${serviceKey}"]`,
101
192
  onError
102
193
  );
103
194
  }
@@ -118,9 +209,9 @@ function createOpaPolicyEvaluator(options) {
118
209
  inputHash = hashInput(input);
119
210
  } catch {
120
211
  }
121
- let rawResult;
212
+ let execution;
122
213
  try {
123
- rawResult = await client.evaluate(binding.decisionPath, input);
214
+ execution = await transport(binding.decisionPath, input);
124
215
  } catch (err) {
125
216
  return makeFailureOutcome(
126
217
  binding,
@@ -131,7 +222,7 @@ function createOpaPolicyEvaluator(options) {
131
222
  inputHash
132
223
  );
133
224
  }
134
- const parsed = parseOpaDecision(rawResult);
225
+ const parsed = parseOpaDecision(execution.decision);
135
226
  if (!parsed.ok) {
136
227
  return makeFailureOutcome(
137
228
  binding,
@@ -139,13 +230,15 @@ function createOpaPolicyEvaluator(options) {
139
230
  "invalid_response",
140
231
  `OPA returned malformed decision: ${parsed.error}`,
141
232
  onError,
142
- inputHash
233
+ inputHash,
234
+ execution
143
235
  );
144
236
  }
145
237
  return buildSuccessOutcome(
146
238
  binding,
147
239
  engineVersion,
148
240
  parsed.decision,
241
+ execution,
149
242
  inputHash,
150
243
  ctx,
151
244
  mapDecision
@@ -153,13 +246,29 @@ function createOpaPolicyEvaluator(options) {
153
246
  }
154
247
  };
155
248
  }
156
- function buildSuccessOutcome(binding, engineVersion, decision, inputHash, ctx, mapDecision) {
157
- const mapped = mapDecision ? mapDecision(decision, ctx) : { result: decision.result, reason: decision.reason };
249
+ function resolveTransport(service) {
250
+ if (!service || typeof service !== "object") return null;
251
+ const exec = service.execute;
252
+ if (typeof exec === "function") {
253
+ return (path, input) => service.execute(path, input);
254
+ }
255
+ const eval_ = service.evaluate;
256
+ if (typeof eval_ === "function") {
257
+ return async (path, input) => ({
258
+ decision: await service.evaluate(path, input)
259
+ });
260
+ }
261
+ return null;
262
+ }
263
+ function buildSuccessOutcome(binding, engineVersion, decision, execution, inputHash, ctx, mapDecision) {
264
+ const selection = selectBundleRevision(execution, binding);
265
+ const mapped = mapDecision ? mapDecision(decision, ctx, execution) : { result: decision.result, reason: decision.reason };
158
266
  return {
159
267
  policyId: binding.policyId,
160
268
  policyVersion: binding.version,
161
- result: mapped.result,
162
- reason: mapped.reason,
269
+ result: mapped.result ?? decision.result,
270
+ reason: mapped.reason ?? decision.reason,
271
+ guidance: mapped.guidance ?? decision.guidance,
163
272
  metadata: {
164
273
  ...mapped.metadata ?? {},
165
274
  opaDecision: decision
@@ -171,30 +280,48 @@ function buildSuccessOutcome(binding, engineVersion, decision, inputHash, ctx, m
171
280
  dispatchPath: ["code"],
172
281
  engine: {
173
282
  name: ENGINE_NAME,
174
- version: engineVersion,
283
+ version: execution.opaVersion ?? engineVersion,
175
284
  metadata: stripUndefined({
176
285
  decisionPath: binding.decisionPath,
177
286
  regoPackage: binding.regoPackage,
178
287
  inputHash,
288
+ decisionId: execution.decisionId,
289
+ bundleName: binding.bundleName,
290
+ bundleRevision: selection.bundleRevision,
291
+ bundleRevisionStatus: selection.bundleRevisionStatus,
292
+ provenance: execution.provenance,
179
293
  conditionResults: decision.conditionResults,
180
- factsUsed: decision.factsUsed,
181
294
  obligations: decision.obligations
182
295
  })
183
296
  }
184
297
  }
185
298
  };
186
299
  }
187
- function makeFailureOutcome(binding, engineVersion, error, reason, onError, inputHash) {
300
+ function makeFailureOutcome(binding, engineVersion, error, reason, onError, inputHash, execution) {
188
301
  if (onError === "throw") {
189
302
  const err = new Error(reason);
190
303
  err.kind = error;
191
304
  throw err;
192
305
  }
306
+ const selection = execution ? selectBundleRevision(execution, binding) : {};
193
307
  return {
194
308
  policyId: binding.policyId,
195
309
  policyVersion: binding.version,
196
310
  result: "block",
197
311
  reason,
312
+ guidance: {
313
+ summary: "OPA policy evaluation could not complete.",
314
+ factsUsed: [
315
+ { name: "opaFailureKind", value: error, label: "Failure kind" },
316
+ { name: "decisionPath", value: binding.decisionPath, label: "Decision path" }
317
+ ],
318
+ correctiveActions: [
319
+ {
320
+ label: "Check OPA policy engine",
321
+ description: "Verify the OPA service is reachable, serving the expected bundle, and returning the Fabric OPA decision shape."
322
+ }
323
+ ]
324
+ },
198
325
  metadata: {
199
326
  opaError: error
200
327
  },
@@ -205,11 +332,16 @@ function makeFailureOutcome(binding, engineVersion, error, reason, onError, inpu
205
332
  dispatchPath: ["code"],
206
333
  engine: {
207
334
  name: ENGINE_NAME,
208
- version: engineVersion,
335
+ version: execution?.opaVersion ?? engineVersion,
209
336
  metadata: stripUndefined({
210
337
  decisionPath: binding.decisionPath,
211
338
  regoPackage: binding.regoPackage,
212
339
  inputHash,
340
+ decisionId: execution?.decisionId,
341
+ bundleName: binding.bundleName,
342
+ bundleRevision: selection.bundleRevision,
343
+ bundleRevisionStatus: selection.bundleRevisionStatus,
344
+ provenance: execution?.provenance,
213
345
  error,
214
346
  errorReason: reason
215
347
  })
@@ -234,9 +366,77 @@ function formatError(err) {
234
366
  }
235
367
  }
236
368
 
369
+ // src/executor-client.ts
370
+ function executorFromOpaClient(client) {
371
+ return {
372
+ async execute(path, input) {
373
+ const decision = await client.evaluate(path, input);
374
+ return { decision };
375
+ }
376
+ };
377
+ }
378
+
379
+ // src/executor-http.ts
380
+ function executorFromOpaHttp(opts) {
381
+ const baseUrl = stripTrailingSlash(opts.baseUrl);
382
+ const provenance = opts.provenance ?? true;
383
+ const customFetch = opts.fetch;
384
+ const headers = {
385
+ "content-type": "application/json",
386
+ accept: "application/json",
387
+ ...opts.headers ?? {}
388
+ };
389
+ return {
390
+ async execute(path, input) {
391
+ const fetchImpl = customFetch ?? globalThis.fetch;
392
+ if (typeof fetchImpl !== "function") {
393
+ throw new Error(
394
+ "executorFromOpaHttp: no fetch implementation available. Pass `fetch` in options or run on a runtime with `globalThis.fetch`."
395
+ );
396
+ }
397
+ const url = `${baseUrl}/v1/data/${stripLeadingSlash(path)}${provenance ? "?provenance=true" : ""}`;
398
+ const response = await fetchImpl(url, {
399
+ method: "POST",
400
+ headers,
401
+ body: JSON.stringify({ input })
402
+ });
403
+ if (!response.ok) {
404
+ const text = await safeReadText(response);
405
+ throw new Error(`OPA HTTP ${response.status} at ${url}: ${truncate(text, 500)}`);
406
+ }
407
+ const body = await response.json();
408
+ return {
409
+ decision: body.result,
410
+ decisionId: body.decision_id,
411
+ opaVersion: body.provenance?.version,
412
+ provenance: body.provenance
413
+ };
414
+ }
415
+ };
416
+ }
417
+ function stripTrailingSlash(s) {
418
+ return s.replace(/\/+$/, "");
419
+ }
420
+ function stripLeadingSlash(s) {
421
+ return s.replace(/^\/+/, "");
422
+ }
423
+ async function safeReadText(response) {
424
+ try {
425
+ return await response.text();
426
+ } catch {
427
+ return "<unreadable body>";
428
+ }
429
+ }
430
+ function truncate(s, max) {
431
+ return s.length <= max ? s : `${s.slice(0, max)}\u2026`;
432
+ }
433
+
237
434
  exports.createOpaPolicyEvaluator = createOpaPolicyEvaluator;
238
435
  exports.defaultHashInput = defaultHashInput;
436
+ exports.executorFromOpaClient = executorFromOpaClient;
437
+ exports.executorFromOpaHttp = executorFromOpaHttp;
239
438
  exports.parseOpaDecision = parseOpaDecision;
439
+ exports.selectBundleRevision = selectBundleRevision;
240
440
  exports.stableStringify = stableStringify;
241
441
  //# sourceMappingURL=index.cjs.map
242
442
  //# sourceMappingURL=index.cjs.map