npm package: @govplane/runtime-sdk
Description: Govplane Runtime SDK (Node/TS)
Latest version: 1.0.0

# @govplane/runtime-sdk

Official **Govplane Runtime SDK** for Node.js.

The Govplane Runtime SDK is a **secure, production-grade client and policy engine** designed to consume **precompiled runtime governance bundles** and evaluate decisions **locally**, with **zero PII**, no exposed endpoints, and minimal operational risk.

It is intended for backend services, workers, gateways, and critical paths that must react to policy changes in near real-time without delegating decisions to a remote service.

More information:
- [Documentation](https://govplane.gitbook.io/docs/)
- [Govplane Website](https://www.govplane.com/)

---

NOTE: This SDK requires a valid Govplane Runtime Key and access to a Govplane Runtime Endpoint. Sign up for a free account to get started: https://app.govplane.com/signup

---

## Architecture

```
┌─────────────────────────────────────────────────────────┐
│                    Your Application                      │
│                                                          │
│  client.evaluate({ target, context })                    │
│         │                                                │
│         ▼                                                │
│  ┌─────────────────┐   bundle cache   ┌───────────────┐ │
│  │  PolicyEngine   │◄─────────────────│ RuntimeClient │ │
│  │  (in-process)   │                  │  (polling)    │ │
│  └────────┬────────┘                  └──────┬────────┘ │
│           │ Decision                         │ HTTP     │
│           ▼                                  ▼          │
│     allow / deny /              Govplane Control Plane   │
│     throttle / kill_switch /    (bundle endpoint only)  │
│     custom                                               │
└─────────────────────────────────────────────────────────┘
```

| Property | Details |
|---|---|
| Decision latency | < 1 ms (in-process evaluation) |
| Network dependency | Bundle fetch only — evaluation is offline-safe |
| PII in traces | None — context is never included in trace events |
| Fail-safe | Deny-by-default when bundle is missing or invalid |
| Bundle delivery | Server-compiled JSON bundle, not evaluated server-side |

---

## Design Principles

- **No exposed endpoints** – no middleware, no inbound surface
- **Local-first evaluation** – decisions are made in-process
- **Zero PII by design** – strict context validation and heuristic PII key blocking
- **Precompiled policies** – no DSL or dynamic code at runtime
- **Deterministic outcomes** – same input, same decision
- **Cheap polling** – HEAD + ETag + conditional GET
- **Failure-safe** – backoff, degraded mode, deny-by-default

---

## Key Capabilities

### Runtime Client

- Efficient **HEAD-first polling** using `ETag` and `If-None-Match`
- In-memory bundle cache
- `warmStart()` to block until first bundle is ready
- Configurable polling interval
- **Burst mode** for incident response
- **Exponential backoff with jitter**
- Automatic **degraded state** with `nextRetryAt` reporting
- Status subscriptions via `onStatus()` and bundle update subscriptions via `onUpdate()`

### Policy Engine

- Five effect types: `allow`, `deny`, `kill_switch`, `throttle`, `custom`
- **Deny-by-default** – no bundle or no match always denies
- Fixed precedence: `kill_switch > deny > throttle > allow > custom > deny-by-default`
- Throttle selects the **most restrictive** matching rule
- `thenEffect` / `elseEffect` for conditional branching within a single rule
- Policy-level `defaults` as a fallback when no rule matches
- Deterministic rule ordering by `priority`, then `policyKey`, then `ruleId`
- Precompiled `when` AST evaluation — no dynamic code execution
- Optional automatic JSON parsing of `custom` effect values (`parseCustomEffect`)

### Security & Context Safety

- Explicit context `allowedKeys` allowlist — unknown keys throw immediately
- Heuristic PII key blocking (`blockLikelyPiiKeys`)
- Configurable limits: `maxStringLen`, `maxArrayLen`
- Context policy is fully configurable per engine instance
- Disable validation only in controlled test environments
- Isolated mode with safe bundle signature verification for air-gapped or incident scenarios.
- HMAC signed bundles for customers with Basic plan. Pro and Enterprise customers can use Ed25519-signed with strict signature and checksum validation.

### Decision Tracing

- `evaluate()` for plain decisions; `evaluateWithTrace()` for traced decisions
- Four trace levels: `off`, `errors`, `sampled`, `full`
- Sampling rate and per-window budget controls
- `force: true` to bypass sampling for debug sessions
- Compact or full format (full includes per-rule breakdown)
- **Synchronous and async trace sinks** with bounded queues and drop strategies
- `flushTraces()` for graceful shutdown

---

## Requirements

- **Node.js ≥ 18**
- A valid **Govplane Runtime Key** (`gprtk_…`)
- An active Govplane Runtime Endpoint (`https://{runtimeId}.runtime.govplane.com/`)

---

## Installation

```bash
npm install @govplane/runtime-sdk
```

```bash
yarn add @govplane/runtime-sdk
# or
pnpm add @govplane/runtime-sdk
```

---

## Quick Start

```typescript
import { RuntimeClient } from "@govplane/runtime-sdk";

const client = new RuntimeClient({
  baseUrl:    "https://123456.runtime.govplane.com/",
  runtimeKey: process.env.GP_RUNTIME_KEY!,
});

await client.warmStart();   // block until the first bundle is cached
client.start();             // begin background polling (every 5 s by default)

const result = client.evaluate({
  target:  { service: "payments", resource: "checkout", action: "create" },
  context: { plan: "pro", country: "ES", isAuthenticated: true },
});

console.log(result.decision); // "allow" | "deny" | "throttle" | "kill_switch" | "custom"
```

---

## Effect Types

The engine returns one of five decision types, applied in this fixed precedence order:

```
kill_switch  >  deny  >  throttle  >  allow  >  custom  >  deny-by-default
```

| `decision`    | When it fires |
|---------------|---------------|
| `allow`       | An `allow` rule matched, or the policy default is `allow`. |
| `deny`        | A `deny` rule matched, no rule matched (deny-by-default), or the policy default is `deny`. |
| `kill_switch` | A `kill_switch` rule or policy default is active. Always wins. |
| `throttle`    | A `throttle` rule or policy default matched. Most restrictive wins. |
| `custom`      | A `custom` rule or policy default matched — carries an arbitrary string value. |

---

## Decision Shape

`evaluate()` always returns a `Decision` object discriminated by `decision`:

```typescript
type Decision =
  | { decision: "allow";
      reason: "default" | "rule"; policyKey?: string; ruleId?: string }
  | { decision: "deny";
      reason: "default" | "rule"; policyKey?: string; ruleId?: string }
  | { decision: "kill_switch";
      reason: "default" | "rule"; policyKey?: string; ruleId?: string;
      killSwitch: { service: string; reason?: string } }
  | { decision: "throttle";
      reason: "default" | "rule"; policyKey?: string; ruleId?: string;
      throttle: { limit: number; windowSeconds: number; key: string } }
  | { decision: "custom";
      reason: "default" | "rule"; policyKey?: string; ruleId?: string;
      value: string; parsedValue?: unknown };
```

### Handling every decision type

```typescript
const result = client.evaluate({ target, context });

if (result.decision === "allow") {
  return next();
}

if (result.decision === "deny") {
  return reply.status(403).send({ error: "Forbidden", policy: result.policyKey });
}

if (result.decision === "throttle") {
  const allowed = await rateLimiter.check(
    result.throttle.key === "tenant" ? ctx.tenantId : ctx.userId,
    result.throttle.limit,
    result.throttle.windowSeconds,
  );
  if (!allowed) return reply.status(429).header("Retry-After", String(result.throttle.windowSeconds)).send();
  return next();
}

if (result.decision === "kill_switch") {
  return reply.status(503).send({ error: "Service Unavailable", reason: result.killSwitch.reason });
}

if (result.decision === "custom") {
  const payload = result.parsedValue ?? JSON.parse(result.value);
  return reply.send(payload);
}
```

> **Note:** The SDK does not maintain rate-limit counters. It signals the throttle parameters; your infrastructure (Redis, middleware, etc.) is responsible for enforcement.

---

## Custom Effects

A `custom` effect carries an arbitrary string — including a JSON-encoded object — back to the caller. Common uses: feature flags, A/B variants, per-tenant configuration overlays.

Enable automatic JSON parsing with `engine.parseCustomEffect`:

```typescript
const client = new RuntimeClient({
  baseUrl:    "https://123456.runtime.govplane.com/",
  runtimeKey: process.env.GP_RUNTIME_KEY!,
  engine: {
    parseCustomEffect: true,   // JSON.parse() applied automatically
  },
});

const result = client.evaluate({ target, context });

if (result.decision === "custom") {
  const flags = result.parsedValue as { enabled: boolean; variant: string };
  if (flags?.enabled) renderNewCheckout();
}
```

Non-JSON strings leave `parsedValue` as `undefined`; the raw `value` string is always present as a fallback.

---

## Conditional Rules (`when` / `thenEffect` / `elseEffect`)

Rules support an optional `when` condition AST evaluated against the call-time context. The AST is compiled by the control plane and distributed in the bundle — the SDK never executes dynamic code.

```
when absent           → always apply effect
when == true          → apply thenEffect   (fallback: effect)
when == false         → apply elseEffect   (fallback: skip rule)
```

Supported operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `exists`, `and`, `or`, `not`.

The `ctx.` prefix on paths is optional and stripped automatically (`ctx.plan` ≡ `plan`).

---

## Policy Defaults

Every policy can declare a `defaults` object — the fallback effect when no rule matches that target. Defaults are treated as a synthetic rule with `priority = -1`, so any explicit rule always wins.

```json
{
  "policyKey": "strict-rbac",
  "defaults":  { "effect": "deny" },
  "rules": [...]
}
```

All five effect types are supported as defaults: `allow`, `deny`, `throttle`, `kill_switch`, `custom`.

---

## Runtime Bundle Model

The SDK retrieves **compiled runtime bundles** generated by the Govplane control plane.

```typescript
type RuntimeBundleV1 = {
  schemaVersion:  1;
  orgId:          string;
  projectId:      string;
  env:            string;
  generatedAt:    string;       // ISO timestamp
  bundleVersion?: number;
  checksum?:      string;       // e.g. "sha256:..."
  policies:       RuntimePolicy[];
};
```

The bundle is treated as **immutable and read-only**. See [Types & Interfaces](docs/reference/TypesAndInterfaces.md) for the full type tree.

---

## Bundle Lifecycle & Polling

All `evaluate()` calls read from an in-memory cache — there is no network hop at decision time.

```typescript
// Block until first bundle is ready, then start background polling
await client.warmStart({ timeoutMs: 10_000 });
client.start();
```

Polling strategy per cycle:
1. `HEAD` the bundle endpoint to check the current `ETag`
2. If `ETag` unchanged → update metadata, skip the `GET`
3. If `ETag` changed → `GET` the full bundle, parse, update cache
4. Notify `onUpdate` listeners

| Phase | Interval |
|---|---|
| Normal | `pollMs` (default **5 000 ms**) |
| Burst mode | `burstPollMs` (default **500 ms**) for `burstDurationMs` (default **30 000 ms**) |
| Backoff | Exponential, capped at `backoffMaxMs` |

### Bundle update subscription

```typescript
client.onUpdate((result) => {
  logger.info("Bundle updated", {
    etag:          result.meta.etag,
    bundleVersion: result.meta.bundleVersion,
    updatedAt:     result.meta.updatedAt,
  });
});
```

---

## Backoff & Degraded Mode

On repeated failures the client applies exponential backoff with jitter and enters **degraded mode** after a configurable number of consecutive failures. The cached bundle remains active in degraded mode.

```typescript
const client = new RuntimeClient({
  ...
  backoffBaseMs:        500,
  backoffMaxMs:         30_000,
  backoffJitter:        0.2,
  degradeAfterFailures: 3,
});

client.onStatus((status) => {
  if (status.state === "degraded") {
    alerting.trigger("govplane_sdk_degraded", {
      failures:    status.consecutiveFailures,
      lastError:   status.lastError.message,
      nextRetryAt: status.nextRetryAt,
    });
  }
});
```

Backoff formula: `delay = clamp(base × 2^(failures-1), 0, backoffMaxMs) ± jitter%`

---

## Isolated Mode

An operating mode in which the SDK severs all control-plane network communication and evaluates policies exclusively from a locally stored **safe bundle**. Activated by setting `GOVPLANE_MODE=isolated`, by creating a trigger file on disk, or by passing `isolation` config at startup. The safe bundle must pass Ed25519 signature verification and SHA-256 checksum validation before it is used. 

### Safe Bundle

A signed variant of the runtime bundle intended for use during incidents or air-gapped deployments. It includes a `signature` object containing an Ed25519 signature over the canonical JSON payload. Safe bundles are generated by the control plane and distributed out-of-band. See [Bundle Verification](security/BundleVerification.md) for the signing and validation specification.

---

## Context Policy & PII Safety

The `context` object is validated synchronously before every evaluation. A violation throws immediately, before any rule is tested.

```typescript
const client = new RuntimeClient({
  ...
  engine: {
    contextPolicy: {
      allowedKeys:        ["plan", "role", "country", "isAuthenticated", "requestTier"],
      maxStringLen:       64,
      maxArrayLen:        10,
      blockLikelyPiiKeys: true,   // blocks email, phone, name, ip, ssn, etc.
    },
  },
});
```

Default allowed keys: `plan`, `country`, `requestTier`, `feature`, `amount`, `isAuthenticated`, `role`.

`object` values are not permitted at the top level. Permitted value types: `string`, `number`, `boolean`, `null/undefined`, `string[]`.

> Disable validation (`engine: { validateContext: false }`) only in controlled test environments.

---

## Decision Tracing

Use `evaluateWithTrace()` to attach a `DecisionTrace` to the result for observability and debugging. Traces contain only structural metadata — no context values or PII.

### Production (sampled)

```typescript
const client = new RuntimeClient({
  ...
  trace: {
    defaults: {
      level:    "sampled",
      sampling: 0.05,   // 5% of evaluations
      budget:   { maxTraces: 60, windowMs: 60_000 },
    },
    onDecisionTraceAsync: async (evt) => {
      await analyticsClient.ingest("govplane.trace", evt);
    },
    queueMax:   1000,
    dropPolicy: "drop_new",
  },
});

const result = client.evaluateWithTrace({ target, context });
if (result.trace) {
  console.log(result.trace.summary);
  console.log(result.trace.winner);
}
```

### Debug (forced)

```typescript
const result = client.evaluateWithTrace(
  { target, context },
  { level: "full", force: true },   // bypass sampling and budget
);
```

| Level | Behaviour |
|---|---|
| `"off"` | No trace computed. Equivalent to `evaluate()`. |
| `"errors"` | Trace attached only on `deny` or `kill_switch`. Compact format. |
| `"sampled"` | Trace attached probabilistically by `sampling` rate and budget. Compact format. |
| `"full"` | Always attempted (budget permitting). Includes complete per-rule list. |

### Trace sinks

Both synchronous (`onDecisionTrace`) and async (`onDecisionTraceAsync`) sinks are supported. Async evaluations are buffered internally and never block evaluation. Drain on shutdown with `client.flushTraces()`.

### Trace guarantees

- No rule bodies
- No context values
- No PII
- Deterministic structure

---

## Runtime Incident Controls

Govplane provides **passive incident controls** that allow operators to respond to incidents without exposing endpoints, handling PII, or recompiling application code.

| Mechanism | Restart Required | Hot | Recommended |
|---|---|---|---|
| Environment Variable (`GP_RUNTIME_INCIDENT=1`) | Usually yes | No | Yes |
| File-based hot reload (`incidentFilePath`) | No | Yes | **Primary** |
| POSIX Signal (`SIGUSR1`) | No | Yes | Optional |

```typescript
const client = new RuntimeClient({
  ...
  incidentFilePath:   "/etc/govplane/incident.json",
  incidentFilePollMs: 1000,
  incidentSignal:     "SIGUSR1",
});
```

Incident file format:
```json
{
  "burst":           true,
  "burstDurationMs": 60000,
  "burstPollMs":     200,
  "refreshNow":      true
}
```

---

## Using `createPolicyEngine` Directly

If you manage the bundle yourself (loaded from a file, injected from config), you can use the engine without `RuntimeClient`:

```typescript
import { createPolicyEngine } from "@govplane/runtime-sdk";
import { readFileSync } from "node:fs";

const bundle = JSON.parse(readFileSync("bundle.json", "utf8"));

const engine = createPolicyEngine({
  getBundle:         () => bundle,
  parseCustomEffect: true,
  contextPolicy: {
    allowedKeys:        ["plan", "role"],
    maxStringLen:       64,
    maxArrayLen:        10,
    blockLikelyPiiKeys: true,
  },
});

const result = engine.evaluate({ target, context });
```

---

## What This SDK Does NOT Do

- No HTTP middleware
- No inbound endpoints
- No request interception
- No PII handling or storage
- No policy authoring
- No dynamic code execution
- No rate-limit counter maintenance

---

## Security Notes

- Runtime keys are **read-only** and scoped by org / project / env
- Bundles are immutable — the SDK cannot modify control-plane state
- Context validation and PII heuristic blocking are on by default
- Safe to embed in critical paths

See [Govplane Runtime SDK Threat Model](docs/security/Govplane_Threat_Model.md) for a full security analysis.

---

## Documentation

Check the full documentation for in-depth guides, API references, and operational best practices: https://govplane.gitbook.io/docs/

---

## License

MIT © Govplane