@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.
@@ -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 %}