@govplane/runtime-sdk 0.2.4 → 0.5.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 +400 -216
- package/dist/index.cjs +187 -91
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +65 -14
- package/dist/index.d.ts +65 -14
- package/dist/index.js +187 -91
- package/dist/index.js.map +1 -1
- package/docs/README.md +89 -0
- package/docs/SUMMARY.md +34 -0
- package/docs/installation/GettingStarted.md +474 -0
- package/docs/reference/Configuration.md +198 -0
- package/docs/reference/TypesAndInterfaces.md +373 -0
- package/docs/usage/BundleLifecycle.md +209 -0
- package/docs/usage/ConditionalRules.md +251 -0
- package/docs/usage/ContextPolicy.md +196 -0
- package/docs/usage/CustomEffect.md +217 -0
- package/docs/usage/DecisionTrace.md +289 -0
- package/docs/usage/Effects.md +213 -0
- package/docs/usage/Evaluate.md +164 -0
- package/docs/usage/PolicyDefaults.md +220 -0
- package/package.json +3 -5
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: >-
|
|
3
|
+
Capture structured, zero-PII decision traces for observability, debugging,
|
|
4
|
+
and audit — with configurable levels, sampling, and async sinks.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Decision Tracing
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
The SDK can optionally record a `DecisionTrace` for each evaluation. Traces contain:
|
|
12
|
+
|
|
13
|
+
- The evaluated `target`
|
|
14
|
+
- A rule-level breakdown of what matched, what was discarded, and why
|
|
15
|
+
- The winning rule and effect
|
|
16
|
+
- A summary of all considered effects
|
|
17
|
+
|
|
18
|
+
**Traces never contain context values or user data.** Only structural metadata (rule IDs, policy keys, effect types, discard reasons) is included.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## `evaluateWithTrace()`
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
const result = client.evaluateWithTrace(
|
|
26
|
+
{ target, context },
|
|
27
|
+
{ level: "full", force: true } // trace options, optional
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
if (result.trace) {
|
|
31
|
+
console.log(result.trace.winner);
|
|
32
|
+
console.log(result.trace.summary);
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The `trace` field is only present when the level, sampling, and budget allow it. When it is absent the `result` object is identical to `evaluate()`.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Trace levels
|
|
41
|
+
|
|
42
|
+
| Level | Behaviour |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `"off"` | No trace is computed or attached. Equivalent to `evaluate()`. |
|
|
45
|
+
| `"errors"` | Trace is attached only when the decision is `deny` or `kill_switch`. Compact format (no rule list). |
|
|
46
|
+
| `"sampled"` | Trace is attached probabilistically based on `sampling` rate and budget. Compact format. |
|
|
47
|
+
| `"full"` | Trace is always attempted (budget permitting). Full format includes the complete per-rule list. |
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Trace formats
|
|
52
|
+
|
|
53
|
+
### Compact (`DecisionTraceCompact`)
|
|
54
|
+
|
|
55
|
+
Contains the summary and winner but no per-rule list. Used by `"errors"` and `"sampled"` levels.
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
type DecisionTraceCompact = {
|
|
59
|
+
traceId: string;
|
|
60
|
+
sampled: "forced" | "random";
|
|
61
|
+
evaluatedAt: string; // ISO timestamp
|
|
62
|
+
target: Target;
|
|
63
|
+
summary: {
|
|
64
|
+
policiesSeen: number;
|
|
65
|
+
rulesSeen: number;
|
|
66
|
+
matched: number;
|
|
67
|
+
considered: {
|
|
68
|
+
kill_switch: number;
|
|
69
|
+
deny: number;
|
|
70
|
+
throttle: number;
|
|
71
|
+
allow: number;
|
|
72
|
+
custom: number;
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
winner?: {
|
|
76
|
+
policyKey: string;
|
|
77
|
+
ruleId: string;
|
|
78
|
+
effectType: string;
|
|
79
|
+
priority: number;
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Full (`DecisionTraceFull`)
|
|
85
|
+
|
|
86
|
+
`DecisionTraceCompact` extended with a per-rule breakdown.
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
type DecisionTraceFull = DecisionTraceCompact & {
|
|
90
|
+
rules: Array<{
|
|
91
|
+
policyKey: string;
|
|
92
|
+
ruleId: string;
|
|
93
|
+
priority: number;
|
|
94
|
+
effectType?: string;
|
|
95
|
+
matched: boolean;
|
|
96
|
+
discardedReason?: "disabled" | "target_mismatch" | "when_false" | "invalid_effect";
|
|
97
|
+
}>;
|
|
98
|
+
};
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Configuring trace defaults
|
|
104
|
+
|
|
105
|
+
Set default trace behaviour via `trace.defaults` in `RuntimeClientConfig`:
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
const client = new RuntimeClient({
|
|
109
|
+
baseUrl: "https://runtime.govplane.io",
|
|
110
|
+
runtimeKey: "rk_live_••••••••",
|
|
111
|
+
trace: {
|
|
112
|
+
defaults: {
|
|
113
|
+
level: "sampled",
|
|
114
|
+
sampling: 0.05, // 5% of evaluations
|
|
115
|
+
budget: {
|
|
116
|
+
maxTraces: 60, // max traces per window
|
|
117
|
+
windowMs: 60_000, // 1-minute window
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Per-call override
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
// Override for a single call — useful in test or debug contexts
|
|
128
|
+
const result = client.evaluateWithTrace(
|
|
129
|
+
{ target, context },
|
|
130
|
+
{ level: "full", force: true } // force=true bypasses sampling and budget
|
|
131
|
+
);
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Trace sinks
|
|
137
|
+
|
|
138
|
+
Traces can be pushed to a synchronous or async sink automatically after each evaluation.
|
|
139
|
+
|
|
140
|
+
### Sync sink (`onDecisionTrace`)
|
|
141
|
+
|
|
142
|
+
Called immediately after each traced evaluation. Keep it fast; errors are caught and forwarded to `onTraceError`.
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
const client = new RuntimeClient({
|
|
146
|
+
...
|
|
147
|
+
trace: {
|
|
148
|
+
defaults: { level: "sampled", sampling: 0.1 },
|
|
149
|
+
onDecisionTrace: (evt) => {
|
|
150
|
+
logger.info("decision_trace", {
|
|
151
|
+
traceId: evt.traceId,
|
|
152
|
+
decision: evt.decision,
|
|
153
|
+
winner: evt.winner?.ruleId,
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Async sink (`onDecisionTraceAsync`)
|
|
161
|
+
|
|
162
|
+
Buffered via an internal queue. Evaluations are never blocked waiting for the sink. Queue is drained asynchronously via `queueMicrotask`.
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
const client = new RuntimeClient({
|
|
166
|
+
...
|
|
167
|
+
trace: {
|
|
168
|
+
defaults: { level: "full" },
|
|
169
|
+
onDecisionTraceAsync: async (evt) => {
|
|
170
|
+
await analyticsClient.ingest("govplane.trace", evt);
|
|
171
|
+
},
|
|
172
|
+
queueMax: 1000, // default 1000
|
|
173
|
+
dropPolicy: "drop_new", // "drop_new" | "drop_old" — default "drop_new"
|
|
174
|
+
onTraceError: (err) => logger.error("trace sink error", err),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
#### Flushing the queue
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
// On graceful shutdown, drain the queue before exiting
|
|
183
|
+
await client.flushTraces();
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## `StructuredTraceEvent`
|
|
189
|
+
|
|
190
|
+
The object delivered to both sync and async sinks:
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
type StructuredTraceEvent = {
|
|
194
|
+
v: 1;
|
|
195
|
+
ts: string; // ISO evaluation timestamp
|
|
196
|
+
traceId: string; // UUID v4
|
|
197
|
+
sampled: "forced" | "random" | "errors";
|
|
198
|
+
level: "off" | "errors" | "sampled" | "full";
|
|
199
|
+
target: Target;
|
|
200
|
+
decision: "allow" | "deny" | "throttle" | "kill_switch" | "custom";
|
|
201
|
+
reason: "rule" | "default";
|
|
202
|
+
winner?: {
|
|
203
|
+
policyKey: string;
|
|
204
|
+
ruleId: string;
|
|
205
|
+
effectType: string;
|
|
206
|
+
priority: number;
|
|
207
|
+
};
|
|
208
|
+
summary: { ... }; // same as DecisionTraceCompact.summary
|
|
209
|
+
rules?: Array<...>; // only present when level === "full"
|
|
210
|
+
};
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## `formatTrace()` utility
|
|
216
|
+
|
|
217
|
+
Pretty-print a trace to a single line (or multi-line) string for log output:
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
import { formatTrace } from "@govplane/runtime-sdk";
|
|
221
|
+
|
|
222
|
+
const result = client.evaluateWithTrace({ target, context }, { level: "full", force: true });
|
|
223
|
+
|
|
224
|
+
if (result.trace) {
|
|
225
|
+
// Single-line (default)
|
|
226
|
+
console.log(formatTrace(result.trace));
|
|
227
|
+
// [govplane] traceId=... sampled=random policies=2 rules=5 matched=1 | winner → ...
|
|
228
|
+
|
|
229
|
+
// Multi-line with discarded rules
|
|
230
|
+
console.log(formatTrace(result.trace, { multiline: true, includeDiscarded: true }));
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## Budget
|
|
237
|
+
|
|
238
|
+
The trace budget prevents trace volume from growing unbounded in high-traffic environments. Traces that exceed the budget are silently skipped (the evaluation result is still returned normally).
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
budget: {
|
|
242
|
+
maxTraces: 60, // max N traces per window
|
|
243
|
+
windowMs: 60_000, // sliding window size in ms
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Use `force: true` to bypass the budget for specific calls (e.g. debugging a production issue):
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
client.evaluateWithTrace({ target, context }, { force: true });
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Integration examples
|
|
256
|
+
|
|
257
|
+
### Ship to Datadog
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
onDecisionTraceAsync: async (evt) => {
|
|
261
|
+
await fetch("https://http-intake.logs.datadoghq.com/api/v2/logs", {
|
|
262
|
+
method: "POST",
|
|
263
|
+
headers: { "DD-API-KEY": process.env.DD_API_KEY!, "Content-Type": "application/json" },
|
|
264
|
+
body: JSON.stringify({
|
|
265
|
+
ddsource: "govplane",
|
|
266
|
+
ddtags: `env:${process.env.NODE_ENV},service:${evt.target.service}`,
|
|
267
|
+
message: JSON.stringify(evt),
|
|
268
|
+
}),
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Write to stdout as NDJSON
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
onDecisionTrace: (evt) => {
|
|
277
|
+
process.stdout.write(JSON.stringify(evt) + "\n");
|
|
278
|
+
},
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
{% content-ref url="BundleLifecycle.md" %}
|
|
284
|
+
[Bundle Lifecycle](BundleLifecycle.md)
|
|
285
|
+
{% endcontent-ref %}
|
|
286
|
+
|
|
287
|
+
{% content-ref url="../reference/Configuration.md" %}
|
|
288
|
+
[Configuration Reference](../reference/Configuration.md)
|
|
289
|
+
{% endcontent-ref %}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: >-
|
|
3
|
+
The five effect types the engine can return, their bundle shapes, and how to
|
|
4
|
+
handle them in application code.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Effect Types
|
|
8
|
+
|
|
9
|
+
The Govplane policy engine recognises five effect types. They are applied in this fixed precedence order:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
kill_switch > deny > throttle > allow > custom > deny-by-default
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## `allow`
|
|
18
|
+
|
|
19
|
+
Grants access. The request proceeds normally.
|
|
20
|
+
|
|
21
|
+
**Bundle rule shape:**
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"id": "r_allow_viewers",
|
|
26
|
+
"status": "active",
|
|
27
|
+
"priority": 10,
|
|
28
|
+
"target": { "service": "api", "resource": "reports", "action": "read" },
|
|
29
|
+
"effect": { "type": "allow" }
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Decision shape:**
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
{ decision: "allow", reason: "rule", policyKey: "reports-policy", ruleId: "r_allow_viewers" }
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## `deny`
|
|
42
|
+
|
|
43
|
+
Blocks access.
|
|
44
|
+
|
|
45
|
+
**Bundle rule shape:**
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"id": "r_deny_guests",
|
|
50
|
+
"status": "active",
|
|
51
|
+
"priority": 5,
|
|
52
|
+
"target": { "service": "api", "resource": "admin", "action": "write" },
|
|
53
|
+
"effect": { "type": "deny" }
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Decision shape:**
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
{ decision: "deny", reason: "rule", policyKey: "admin-policy", ruleId: "r_deny_guests" }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Deny-by-default:** If no rule matches and no policy default applies, the SDK returns `{ decision: "deny", reason: "default" }` automatically. There is no `policyKey` or `ruleId` in this case.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## `throttle`
|
|
68
|
+
|
|
69
|
+
Rate-limits the request. Your application is responsible for enforcing the limit; the SDK only signals the intent.
|
|
70
|
+
|
|
71
|
+
**Bundle rule shape:**
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"id": "r_throttle_free",
|
|
76
|
+
"status": "active",
|
|
77
|
+
"priority": 20,
|
|
78
|
+
"target": { "service": "api", "resource": "export", "action": "create" },
|
|
79
|
+
"effect": {
|
|
80
|
+
"type": "throttle",
|
|
81
|
+
"throttle": {
|
|
82
|
+
"limit": 10,
|
|
83
|
+
"windowSeconds": 3600,
|
|
84
|
+
"key": "tenant"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Decision shape:**
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
{
|
|
94
|
+
decision: "throttle",
|
|
95
|
+
reason: "rule",
|
|
96
|
+
policyKey: "export-policy",
|
|
97
|
+
ruleId: "r_throttle_free",
|
|
98
|
+
throttle: { limit: 10, windowSeconds: 3600, key: "tenant" }
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
| `throttle` field | Description |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `limit` | Maximum number of requests allowed per window. |
|
|
105
|
+
| `windowSeconds` | Duration of the sliding window in seconds. |
|
|
106
|
+
| `key` | The entity to key the counter on (`"tenant"`, `"user"`, `"ip"`, etc.). Your app must read this and apply the counter accordingly. |
|
|
107
|
+
|
|
108
|
+
{% hint style="info" %}
|
|
109
|
+
The SDK does not maintain counters. It signals the throttle intent and parameters. Your infrastructure (middleware, Redis, etc.) is responsible for tracking and enforcing the rate limit.
|
|
110
|
+
{% endhint %}
|
|
111
|
+
|
|
112
|
+
**Handling a throttle:**
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
if (result.decision === "throttle") {
|
|
116
|
+
const allowed = await rateLimiter.check(
|
|
117
|
+
result.throttle.key === "tenant" ? ctx.tenantId : ctx.userId,
|
|
118
|
+
result.throttle.limit,
|
|
119
|
+
result.throttle.windowSeconds,
|
|
120
|
+
);
|
|
121
|
+
if (!allowed) {
|
|
122
|
+
return reply.status(429)
|
|
123
|
+
.header("Retry-After", String(result.throttle.windowSeconds))
|
|
124
|
+
.send({ error: "Too Many Requests" });
|
|
125
|
+
}
|
|
126
|
+
// within limit — proceed
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**Strictest-wins rule:** When multiple `throttle` rules match, the engine selects the one with the lowest request-per-second rate (i.e. the most restrictive limit), not the one with the highest priority.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## `kill_switch`
|
|
135
|
+
|
|
136
|
+
Immediately blocks all traffic to a service. Designed for incidents and emergency shutdowns.
|
|
137
|
+
|
|
138
|
+
**Bundle rule shape:**
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"id": "r_kill_payments",
|
|
143
|
+
"status": "active",
|
|
144
|
+
"priority": 1,
|
|
145
|
+
"target": { "service": "payments", "resource": "*", "action": "*" },
|
|
146
|
+
"effect": {
|
|
147
|
+
"type": "kill_switch",
|
|
148
|
+
"killSwitch": {
|
|
149
|
+
"service": "payments",
|
|
150
|
+
"reason": "Database degradation detected — incident INC-4421"
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Decision shape:**
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
{
|
|
160
|
+
decision: "kill_switch",
|
|
161
|
+
reason: "rule",
|
|
162
|
+
policyKey: "payments-circuit-breaker",
|
|
163
|
+
ruleId: "r_kill_payments",
|
|
164
|
+
killSwitch: { service: "payments", reason: "Database degradation detected — incident INC-4421" }
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**Handling a kill switch:**
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
if (result.decision === "kill_switch") {
|
|
172
|
+
logger.error("kill_switch active", { service: result.killSwitch.service });
|
|
173
|
+
return reply.status(503).send({
|
|
174
|
+
error: "Service Unavailable",
|
|
175
|
+
reason: result.killSwitch.reason,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
{% hint style="danger" %}
|
|
181
|
+
`kill_switch` has the highest precedence. It overrides any `allow`, `deny`, `throttle`, or `custom` decision from any other rule in any policy.
|
|
182
|
+
{% endhint %}
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## `custom`
|
|
187
|
+
|
|
188
|
+
Returns an arbitrary string value — including a JSON-encoded object — defined in the bundle. Useful for feature flags, A/B variants, contextual metadata, or any structured response that does not fit the other effect types.
|
|
189
|
+
|
|
190
|
+
See [Custom Effects](CustomEffect.md) for the full reference.
|
|
191
|
+
|
|
192
|
+
**Decision shape (summary):**
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
{
|
|
196
|
+
decision: "custom",
|
|
197
|
+
reason: "rule",
|
|
198
|
+
policyKey: "feature-flags",
|
|
199
|
+
ruleId: "r_flags_pro",
|
|
200
|
+
value: "{\"newCheckout\": true, \"aiSearch\": false}",
|
|
201
|
+
parsedValue: { newCheckout: true, aiSearch: false } // present when parseCustomEffect is enabled
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
{% content-ref url="CustomEffect.md" %}
|
|
208
|
+
[Custom Effects](CustomEffect.md)
|
|
209
|
+
{% endcontent-ref %}
|
|
210
|
+
|
|
211
|
+
{% content-ref url="PolicyDefaults.md" %}
|
|
212
|
+
[Policy Defaults](PolicyDefaults.md)
|
|
213
|
+
{% endcontent-ref %}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: >-
|
|
3
|
+
The two evaluation methods, their inputs and outputs, and how to handle every
|
|
4
|
+
decision type.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Evaluating Decisions
|
|
8
|
+
|
|
9
|
+
## `evaluate()`
|
|
10
|
+
|
|
11
|
+
The primary API. Synchronous, in-process, sub-millisecond.
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
const result = client.evaluate({
|
|
15
|
+
target: {
|
|
16
|
+
service: "api", // logical service name
|
|
17
|
+
resource: "invoices", // resource identifier
|
|
18
|
+
action: "create", // action being performed
|
|
19
|
+
},
|
|
20
|
+
context: {
|
|
21
|
+
role: "billing_admin",
|
|
22
|
+
plan: "enterprise",
|
|
23
|
+
isAuthenticated: true,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Return value — `Decision`
|
|
29
|
+
|
|
30
|
+
`evaluate()` always returns a `Decision` object. The `decision` discriminant tells you which effect was applied.
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
type Decision =
|
|
34
|
+
| { decision: "allow"; reason: "default" | "rule"; policyKey?: string; ruleId?: string }
|
|
35
|
+
| { decision: "deny"; reason: "default" | "rule"; policyKey?: string; ruleId?: string }
|
|
36
|
+
| { decision: "kill_switch"; reason: "default" | "rule"; policyKey?: string; ruleId?: string;
|
|
37
|
+
killSwitch: { service: string; reason?: string } }
|
|
38
|
+
| { decision: "throttle"; reason: "default" | "rule"; policyKey?: string; ruleId?: string;
|
|
39
|
+
throttle: { limit: number; windowSeconds: number; key: string } }
|
|
40
|
+
| { decision: "custom"; reason: "default" | "rule"; policyKey?: string; ruleId?: string;
|
|
41
|
+
value: string; parsedValue?: unknown };
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
| Field | Description |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `decision` | The effect that was applied. |
|
|
47
|
+
| `reason` | `"rule"` when a matching rule produced the effect; `"default"` when a policy default or the SDK's global deny-by-default fired. |
|
|
48
|
+
| `policyKey` | The policy that produced the decision (absent on global deny-by-default). |
|
|
49
|
+
| `ruleId` | The specific rule that matched (absent when `reason` is `"default"`). |
|
|
50
|
+
|
|
51
|
+
### Handling every decision type
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
const result = client.evaluate({ target, context });
|
|
55
|
+
|
|
56
|
+
if (result.decision === "allow") {
|
|
57
|
+
return next(); // proceed
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (result.decision === "deny") {
|
|
61
|
+
return reply.status(403).send({ error: "Forbidden", policy: result.policyKey });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (result.decision === "throttle") {
|
|
65
|
+
return reply.status(429)
|
|
66
|
+
.header("Retry-After", String(result.throttle.windowSeconds))
|
|
67
|
+
.send({ error: "Too Many Requests", limit: result.throttle.limit });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (result.decision === "kill_switch") {
|
|
71
|
+
return reply.status(503).send({
|
|
72
|
+
error: "Service Unavailable",
|
|
73
|
+
reason: result.killSwitch.reason,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (result.decision === "custom") {
|
|
78
|
+
// raw string value always available
|
|
79
|
+
const payload = result.parsedValue ?? JSON.parse(result.value);
|
|
80
|
+
return reply.send(payload);
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## `evaluateWithTrace()`
|
|
87
|
+
|
|
88
|
+
Same as `evaluate()` but can attach a `DecisionTrace` to the returned object for observability. The trace level and sampling rate are controlled by your configuration.
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
const result = client.evaluateWithTrace(
|
|
92
|
+
{ target, context },
|
|
93
|
+
{ level: "full", force: true } // per-call override (optional)
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (result.trace) {
|
|
97
|
+
logger.info(result.trace);
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The trace is only present when the configured `level`, `sampling`, and `budget` allow it. See [Decision Tracing](DecisionTrace.md) for the full reference.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Target matching
|
|
106
|
+
|
|
107
|
+
A rule matches a call when all three fields in `target` are identical (exact string match, case-sensitive).
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
// This call matches only rules whose target is exactly:
|
|
111
|
+
// service="payments", resource="checkout", action="create"
|
|
112
|
+
client.evaluate({
|
|
113
|
+
target: { service: "payments", resource: "checkout", action: "create" },
|
|
114
|
+
context: { ... },
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
{% hint style="warning" %}
|
|
119
|
+
There are no wildcards in target matching. Each service/resource/action combination is an exact literal string. Design your target namespace accordingly.
|
|
120
|
+
{% endhint %}
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Decision precedence
|
|
125
|
+
|
|
126
|
+
When multiple rules across multiple policies match the same target, the SDK applies a fixed precedence order:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
kill_switch > deny > throttle > allow > custom > deny-by-default
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Within the same effect type, the rule with the **highest `priority`** value wins. Ties are broken lexicographically by `policyKey`, then `ruleId`.
|
|
133
|
+
|
|
134
|
+
Policy-level defaults are treated as synthetic rules with `priority = -1`, so any explicit rule always wins over a policy default.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Using `createPolicyEngine` directly
|
|
139
|
+
|
|
140
|
+
If you manage the bundle yourself (e.g. read from a file, injected via config) you can bypass `RuntimeClient` and use the engine standalone:
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
import { createPolicyEngine } from "@govplane/runtime-sdk";
|
|
144
|
+
import { readFileSync } from "node:fs";
|
|
145
|
+
|
|
146
|
+
const bundle = JSON.parse(readFileSync("bundle.json", "utf8"));
|
|
147
|
+
|
|
148
|
+
const engine = createPolicyEngine({
|
|
149
|
+
getBundle: () => bundle,
|
|
150
|
+
parseCustomEffect: true,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const result = engine.evaluate({ target, context });
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
{% content-ref url="Effects.md" %}
|
|
159
|
+
[Effect Types](Effects.md)
|
|
160
|
+
{% endcontent-ref %}
|
|
161
|
+
|
|
162
|
+
{% content-ref url="DecisionTrace.md" %}
|
|
163
|
+
[Decision Tracing](DecisionTrace.md)
|
|
164
|
+
{% endcontent-ref %}
|