@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,373 @@
1
+ ---
2
+ description: >-
3
+ TypeScript type definitions exported by the SDK — effects, decisions, traces,
4
+ bundles, and engine interfaces.
5
+ ---
6
+
7
+ # Types & Interfaces
8
+
9
+ All types are available as named exports from `@govplane/runtime-sdk`.
10
+
11
+ ```typescript
12
+ import type {
13
+ RuntimeClientConfig,
14
+ Decision,
15
+ Effect,
16
+ Target,
17
+ RuntimePolicy,
18
+ RuntimeBundleV1,
19
+ TraceOptions,
20
+ StructuredTraceEvent,
21
+ // ...
22
+ } from "@govplane/runtime-sdk";
23
+ ```
24
+
25
+ ---
26
+
27
+ ## Core evaluation types
28
+
29
+ ### `Target`
30
+
31
+ Identifies the resource being accessed. All three fields must match exactly for a rule to apply.
32
+
33
+ ```typescript
34
+ type Target = {
35
+ service: string; // e.g. "api", "payments", "app"
36
+ resource: string; // e.g. "invoices", "feature/checkout-v2"
37
+ action: string; // e.g. "read", "create", "delete"
38
+ };
39
+ ```
40
+
41
+ ### `Effect`
42
+
43
+ The effect stored inside a rule or policy default.
44
+
45
+ ```typescript
46
+ type Effect =
47
+ | { type: "allow" }
48
+ | { type: "deny" }
49
+ | { type: "kill_switch"; killSwitch: { service: string; reason?: string } }
50
+ | { type: "throttle"; throttle: { limit: number; windowSeconds: number; key: string } }
51
+ | { type: "custom"; value: string };
52
+ ```
53
+
54
+ ### `Decision`
55
+
56
+ The output of `evaluate()`. Discriminated by `decision`.
57
+
58
+ ```typescript
59
+ type Decision =
60
+ | { decision: "allow";
61
+ reason: "default" | "rule"; policyKey?: string; ruleId?: string }
62
+
63
+ | { decision: "deny";
64
+ reason: "default" | "rule"; policyKey?: string; ruleId?: string }
65
+
66
+ | { decision: "kill_switch";
67
+ reason: "default" | "rule"; policyKey?: string; ruleId?: string;
68
+ killSwitch: { service: string; reason?: string } }
69
+
70
+ | { decision: "throttle";
71
+ reason: "default" | "rule"; policyKey?: string; ruleId?: string;
72
+ throttle: { limit: number; windowSeconds: number; key: string } }
73
+
74
+ | { decision: "custom";
75
+ reason: "default" | "rule"; policyKey?: string; ruleId?: string;
76
+ value: string; parsedValue?: unknown };
77
+ ```
78
+
79
+ | Field | Description |
80
+ |---|---|
81
+ | `decision` | Effect type that was applied. |
82
+ | `reason` | `"rule"` — a matching rule fired. `"default"` — a policy default or the SDK global deny-by-default was used. |
83
+ | `policyKey` | Policy that produced the decision. `undefined` on global deny-by-default. |
84
+ | `ruleId` | Rule that matched. `undefined` when `reason === "default"`. |
85
+ | `value` | (custom only) Raw string from the bundle. |
86
+ | `parsedValue` | (custom only) JSON-parsed `value`. Present only when `parseCustomEffect: true` and the value is valid JSON. |
87
+
88
+ ---
89
+
90
+ ## Bundle types
91
+
92
+ ### `RuntimeBundleV1`
93
+
94
+ The top-level compiled bundle document.
95
+
96
+ ```typescript
97
+ type RuntimeBundleV1 = {
98
+ schemaVersion: 1;
99
+ orgId: string;
100
+ projectId: string;
101
+ env: string;
102
+ generatedAt: string; // ISO timestamp
103
+ bundleVersion?: number;
104
+ checksum?: string; // e.g. "sha256:..."
105
+ policies: RuntimePolicy[];
106
+ };
107
+ ```
108
+
109
+ ### `RuntimePolicy`
110
+
111
+ A single policy within the bundle.
112
+
113
+ ```typescript
114
+ type RuntimePolicy = {
115
+ policyKey: string;
116
+ activeVersion: number;
117
+ defaults?: PolicyDefault; // fallback effect when no rule matches
118
+ rules: RuntimeRule[];
119
+ };
120
+ ```
121
+
122
+ ### `PolicyDefault`
123
+
124
+ The fallback effect applied at the policy level.
125
+
126
+ ```typescript
127
+ type PolicyDefault =
128
+ | { effect: "allow" }
129
+ | { effect: "deny" }
130
+ | { effect: "kill_switch"; killSwitch: { service: string; reason?: string } }
131
+ | { effect: "throttle"; throttle: { limit: number; windowSeconds: number; key: string } }
132
+ | { effect: "custom"; customEffect: string };
133
+ ```
134
+
135
+ ### `RuntimeRule`
136
+
137
+ An individual rule inside a policy.
138
+
139
+ ```typescript
140
+ type RuntimeRule = {
141
+ id: string;
142
+ status: "active" | "disabled";
143
+ priority: number;
144
+ target: Target;
145
+ when?: WhenAstV1; // condition AST
146
+ thenEffect?: Effect; // effect when when == true (fallback: effect)
147
+ elseEffect?: Effect; // effect when when == false (fallback: skip)
148
+ effect: Effect; // unconditional / fallback effect
149
+ description?: string;
150
+ };
151
+ ```
152
+
153
+ ### `WhenAstV1`
154
+
155
+ The condition AST node. Recursive (logical operators contain child nodes).
156
+
157
+ ```typescript
158
+ type WhenAstV1 =
159
+ | { op: "and" | "or"; conditions: WhenAstV1[] }
160
+ | { op: "not"; condition: WhenAstV1 }
161
+ | { op: "eq" | "neq" | "gt" | "gte" | "lt" | "lte"; path: string; value: any }
162
+ | { op: "in"; path: string; values: any[] }
163
+ | { op: "exists"; path: string };
164
+ ```
165
+
166
+ ---
167
+
168
+ ## Client types
169
+
170
+ ### `RuntimeClientConfig`
171
+
172
+ See the full [Configuration Reference](Configuration.md).
173
+
174
+ ### `RuntimeStatus`
175
+
176
+ ```typescript
177
+ type RuntimeStatus =
178
+ | { state: "warming_up" }
179
+ | { state: "ok" }
180
+ | {
181
+ state: "degraded";
182
+ consecutiveFailures: number;
183
+ lastError: { message: string; at: string };
184
+ nextRetryAt?: string;
185
+ };
186
+ ```
187
+
188
+ ### `BundleMeta`
189
+
190
+ ```typescript
191
+ type BundleMeta = {
192
+ etag: string;
193
+ bundleVersion?: number;
194
+ updatedAt?: string;
195
+ };
196
+ ```
197
+
198
+ ### `RuntimeCache<TBundle>`
199
+
200
+ ```typescript
201
+ type RuntimeCache<TBundle = unknown> = {
202
+ meta?: BundleMeta;
203
+ bundle?: TBundle;
204
+ };
205
+ ```
206
+
207
+ ### `RefreshResult<TBundle>`
208
+
209
+ Returned by `refreshNow()` and `onUpdate()` callbacks.
210
+
211
+ ```typescript
212
+ type RefreshResult<TBundle = unknown> =
213
+ | { changed: false; meta?: BundleMeta }
214
+ | { changed: true; meta: BundleMeta; bundle: TBundle };
215
+ ```
216
+
217
+ ---
218
+
219
+ ## Engine types
220
+
221
+ ### `PolicyEngine`
222
+
223
+ The low-level interface returned by `createPolicyEngine()`.
224
+
225
+ ```typescript
226
+ type PolicyEngine = {
227
+ evaluate(input: { target: Target; context?: Record<string, unknown> }): Decision;
228
+
229
+ evaluateWithTrace(
230
+ input: { target: Target; context?: Record<string, unknown> },
231
+ options?: TraceOptions
232
+ ): DecisionWithOptionalTrace;
233
+
234
+ flushTraces(): Promise<void>;
235
+ };
236
+ ```
237
+
238
+ ---
239
+
240
+ ## Trace types
241
+
242
+ ### `TraceOptions`
243
+
244
+ ```typescript
245
+ type TraceOptions = {
246
+ level?: "off" | "errors" | "sampled" | "full";
247
+ sampling?: number; // 0..1
248
+ force?: boolean;
249
+ budget?: {
250
+ maxTraces: number;
251
+ windowMs: number;
252
+ };
253
+ };
254
+ ```
255
+
256
+ ### `DecisionTraceCompact`
257
+
258
+ Attached to decisions when `level` is `"errors"` or `"sampled"`.
259
+
260
+ ```typescript
261
+ type DecisionTraceCompact = {
262
+ traceId: string;
263
+ sampled: "forced" | "random";
264
+ evaluatedAt: string;
265
+ target: Target;
266
+ summary: {
267
+ policiesSeen: number;
268
+ rulesSeen: number;
269
+ matched: number;
270
+ considered: {
271
+ kill_switch: number;
272
+ deny: number;
273
+ throttle: number;
274
+ allow: number;
275
+ custom: number;
276
+ };
277
+ };
278
+ winner?: {
279
+ policyKey: string;
280
+ ruleId: string;
281
+ effectType: string;
282
+ priority: number;
283
+ };
284
+ };
285
+ ```
286
+
287
+ ### `DecisionTraceFull`
288
+
289
+ Extends `DecisionTraceCompact` with a per-rule breakdown. Present when `level === "full"`.
290
+
291
+ ```typescript
292
+ type DecisionTraceFull = DecisionTraceCompact & {
293
+ rules: Array<{
294
+ policyKey: string;
295
+ ruleId: string;
296
+ priority: number;
297
+ effectType?: string;
298
+ matched: boolean;
299
+ discardedReason?: "disabled" | "target_mismatch" | "when_false" | "invalid_effect";
300
+ }>;
301
+ };
302
+ ```
303
+
304
+ ### `StructuredTraceEvent`
305
+
306
+ The object delivered to `onDecisionTrace` / `onDecisionTraceAsync` sinks.
307
+
308
+ ```typescript
309
+ type StructuredTraceEvent = {
310
+ v: 1;
311
+ ts: string;
312
+ traceId: string;
313
+ sampled: "forced" | "random" | "errors";
314
+ level: TraceLevel;
315
+ target: Target;
316
+ decision: Decision["decision"];
317
+ reason: Decision["reason"];
318
+ winner?: { policyKey: string; ruleId: string; effectType: string; priority: number };
319
+ summary: { /* same as DecisionTraceCompact.summary */ };
320
+ rules?: Array<{ /* same as DecisionTraceFull.rules entry */ }>; // level === "full" only
321
+ };
322
+ ```
323
+
324
+ ### `TraceSink` / `TraceSinkAsync`
325
+
326
+ ```typescript
327
+ type TraceSink = (evt: StructuredTraceEvent) => void;
328
+ type TraceSinkAsync = (evt: StructuredTraceEvent) => Promise<void>;
329
+ ```
330
+
331
+ ---
332
+
333
+ ## Error classes
334
+
335
+ ### `GovplaneError`
336
+
337
+ Base error class for all SDK errors.
338
+
339
+ ```typescript
340
+ class GovplaneError extends Error {
341
+ readonly code: string; // "GP_ERROR" by default
342
+ readonly details: unknown;
343
+ }
344
+ ```
345
+
346
+ ### `HttpError`
347
+
348
+ Thrown when the bundle fetch returns a non-2xx response.
349
+
350
+ ```typescript
351
+ class HttpError extends GovplaneError {
352
+ readonly status: number; // HTTP status code
353
+ readonly headers: Record<string, string> | undefined;
354
+ // code = "HTTP_ERROR"
355
+ }
356
+ ```
357
+
358
+ ---
359
+
360
+ ## Context types
361
+
362
+ ### `ContextPolicy`
363
+
364
+ ```typescript
365
+ type ContextPolicy = {
366
+ allowedKeys: string[];
367
+ maxStringLen: number;
368
+ maxArrayLen: number;
369
+ blockLikelyPiiKeys: boolean;
370
+ };
371
+ ```
372
+
373
+ The `DEFAULT_CONTEXT_POLICY` export contains the out-of-the-box values. See [Context Policy](../usage/ContextPolicy.md).
@@ -0,0 +1,209 @@
1
+ ---
2
+ description: >-
3
+ How the SDK fetches and keeps the runtime bundle fresh — polling, backoff,
4
+ degraded mode, and burst refreshes.
5
+ ---
6
+
7
+ # Bundle Lifecycle
8
+
9
+ ## Overview
10
+
11
+ The `RuntimeClient` maintains a local in-memory copy of the compiled runtime bundle. All `evaluate()` calls read from this cache — there is no network hop at decision time.
12
+
13
+ The bundle is kept up to date via a background HTTP polling loop with automatic exponential backoff and a degraded-mode watchdog.
14
+
15
+ ```
16
+ ┌──────────────┐ HEAD (ETag check) ┌──────────────────────┐
17
+ │ │──────────────────────────────────▶│ │
18
+ │ RuntimeClient│ GET (only when ETag changed) │ Govplane Bundle API │
19
+ │ (in-memory) │◀──────────────────────────────────│ │
20
+ └──────────────┘ └──────────────────────┘
21
+
22
+ │ cached bundle
23
+
24
+ PolicyEngine.evaluate() ← sub-millisecond, no network
25
+ ```
26
+
27
+ ---
28
+
29
+ ## Startup
30
+
31
+ ### `warmStart(opts?)` — recommended
32
+
33
+ Blocks until a valid bundle is cached or the timeout is exceeded. Use this before your server starts accepting traffic.
34
+
35
+ ```typescript
36
+ await client.warmStart({ timeoutMs: 10_000 });
37
+ client.start(); // then begin background polling
38
+ ```
39
+
40
+ ```typescript
41
+ type WarmStartOptions = {
42
+ timeoutMs?: number; // default 10 000 ms
43
+ burst?: boolean; // use burstPollMs during warm-up
44
+ };
45
+ ```
46
+
47
+ If no valid bundle is received within `timeoutMs`, `warmStart()` throws:
48
+
49
+ ```
50
+ Error: warmStart timeout: no valid runtime bundle received
51
+ ```
52
+
53
+ ### `start()` — background polling
54
+
55
+ Starts the polling loop. Safe to call multiple times (idempotent).
56
+
57
+ ```typescript
58
+ client.start();
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Polling behaviour
64
+
65
+ | Phase | Interval |
66
+ |---|---|
67
+ | Normal | `pollMs` (default **5 000 ms**) |
68
+ | Burst mode | `burstPollMs` (default **500 ms**) for `burstDurationMs` (default **30 000 ms**) |
69
+ | Backoff (after failures) | Exponential, capped at `backoffMaxMs` |
70
+
71
+ ### Bundle fetch flow
72
+
73
+ Each poll cycle:
74
+
75
+ 1. **HEAD** the bundle endpoint to check the current `ETag`.
76
+ 2. If `ETag` is unchanged → update `meta`, skip the `GET`.
77
+ 3. If `ETag` changed → **GET** the full bundle body, parse, and update the cache.
78
+ 4. Notify `onUpdate` listeners.
79
+
80
+ ---
81
+
82
+ ## Backoff & degraded mode
83
+
84
+ After a configurable number of consecutive failures the client enters **degraded mode**. Polling continues but at increasingly long intervals.
85
+
86
+ ```typescript
87
+ const client = new RuntimeClient({
88
+ ...
89
+ backoffBaseMs: 500, // initial backoff (default 500 ms)
90
+ backoffMaxMs: 30_000, // maximum backoff (default 30 s)
91
+ backoffJitter: 0.2, // ±20% jitter (default 0.2)
92
+ degradeAfterFailures: 3, // failures before degraded (default 3)
93
+ });
94
+ ```
95
+
96
+ Backoff formula: `delay = clamp(base × 2^(failures-1), 0, backoffMaxMs) ± jitter%`
97
+
98
+ ### `getStatus()`
99
+
100
+ ```typescript
101
+ type RuntimeStatus =
102
+ | { state: "warming_up" }
103
+ | { state: "ok" }
104
+ | {
105
+ state: "degraded";
106
+ consecutiveFailures: number;
107
+ lastError: { message: string; at: string };
108
+ nextRetryAt?: string; // ISO timestamp of next scheduled retry
109
+ };
110
+ ```
111
+
112
+ ```typescript
113
+ const status = client.getStatus();
114
+
115
+ if (status.state === "degraded") {
116
+ alerting.trigger("govplane_sdk_degraded", {
117
+ failures: status.consecutiveFailures,
118
+ lastError: status.lastError.message,
119
+ nextRetryAt: status.nextRetryAt,
120
+ });
121
+ }
122
+ ```
123
+
124
+ ### `onStatus(fn)` — status change subscription
125
+
126
+ ```typescript
127
+ const unsub = client.onStatus((status) => {
128
+ metrics.gauge("govplane.sdk.degraded", status.state === "degraded" ? 1 : 0);
129
+ });
130
+
131
+ // Later, to unsubscribe:
132
+ unsub();
133
+ ```
134
+
135
+ ---
136
+
137
+ ## Bundle update subscription
138
+
139
+ ```typescript
140
+ const unsub = client.onUpdate((result) => {
141
+ // result.changed is always true here (the listener only fires on changes)
142
+ logger.info("Bundle updated", {
143
+ etag: result.meta.etag,
144
+ bundleVersion: result.meta.bundleVersion,
145
+ updatedAt: result.meta.updatedAt,
146
+ });
147
+ });
148
+ ```
149
+
150
+ ---
151
+
152
+ ## Manual refresh
153
+
154
+ ```typescript
155
+ // Trigger an immediate refresh
156
+ const result = await client.refreshNow();
157
+
158
+ if (result.changed) {
159
+ console.log("New bundle version:", result.meta.bundleVersion);
160
+ }
161
+
162
+ // Trigger a burst refresh (also enables burst polling for burstDurationMs)
163
+ await client.refreshNow({ burst: true });
164
+ ```
165
+
166
+ ---
167
+
168
+ ## Burst mode
169
+
170
+ Burst mode temporarily switches the poll interval from `pollMs` to `burstPollMs`. It is used:
171
+
172
+ - Manually via `refreshNow({ burst: true })`
173
+ - During incident-mode activation (file trigger or signal handler)
174
+
175
+ Burst mode automatically expires after `burstDurationMs` (default 30 s).
176
+
177
+ ---
178
+
179
+ ## Stopping gracefully
180
+
181
+ ```typescript
182
+ process.on("SIGTERM", async () => {
183
+ client.stop(); // stop polling timers
184
+ await client.flushTraces(); // drain the async trace queue
185
+ process.exit(0);
186
+ });
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Cache inspection
192
+
193
+ ```typescript
194
+ const cache = client.getCached();
195
+
196
+ console.log(cache.meta?.etag);
197
+ console.log(cache.meta?.bundleVersion);
198
+ console.log(cache.bundle); // the raw RuntimeBundleV1 object
199
+ ```
200
+
201
+ ---
202
+
203
+ {% content-ref url="../operations/Govplane_Incident_Playbook.md" %}
204
+ [Incident Playbook](../operations/Govplane_Incident_Playbook.md)
205
+ {% endcontent-ref %}
206
+
207
+ {% content-ref url="../reference/Configuration.md" %}
208
+ [Configuration Reference](../reference/Configuration.md)
209
+ {% endcontent-ref %}