> For the complete documentation index, see [llms.txt](https://govplane.gitbook.io/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://govplane.gitbook.io/docs/documentation/sdk-for-javascript-node.js/best-practices.md).

# Best Practices

Opinionated guidance for running `@govplane/runtime-sdk` in production.

### The client must be a singleton

The client owns a verified bundle, a file watcher or poller, and one engine. Construct it at startup and export it — **never per request**.

```ts
// govplane.ts
import { createLocalClient } from '@govplane/runtime-sdk';

export const govplane = await createLocalClient({
  bundlePath: process.env.GOVPLANE_BUNDLE_PATH!,
  verify: { algorithm: 'ECDSA_SHA_256', publicKey: process.env.GOVPLANE_PUBLIC_KEY! },
  watch: { intervalMs: 30_000 },
});
```

A per-request client would re-read and re-verify the bundle on every call, and leak a watcher each time.

```ts
process.on('SIGTERM', () => govplane.close());
```

`close()` stops the watcher or poller. Without it, a process can hang on shutdown.

### Fail fast at boot

Let the factory reject. A process that cannot load its policy bundle should not start serving traffic with no policy — that is precisely the situation the SDK exists to prevent.

```ts
// Good: the process exits, the orchestrator restarts it, the alert fires
export const govplane = await createLocalClient({ … });

// Bad: silently unprotected
let govplane;
try { govplane = await createLocalClient({ … }); } catch { /* carry on */ }
```

If you need to degrade rather than crash, do it deliberately and loudly: ship a known-good fallback bundle in the image and load that, with a metric attached.

### Always verify in production

`allowUnsigned: true` is a development convenience. In production, sign the bundle and configure the key:

```bash
govplane build --signed
```

```ts
verify: { algorithm: 'ECDSA_SHA_256', publicKey: process.env.GOVPLANE_PUBLIC_KEY }
```

An unverified bundle is an unauthenticated instruction about who may do what, arriving from wherever your deployment pipeline happens to put files.

{% hint style="info" %}
The **public** key is not a secret — it can be committed, baked into the image, or shipped in a ConfigMap. Only the private half needs protecting, and it never touches the SDK.
{% endhint %}

### Log status at boot, alert on signature

```ts
const status = govplane.status();
logger.info({ govplane: status }, 'policy bundle loaded');

if (status.signature.status !== 'verified') {
  logger.error({ govplane: status }, 'running an unverified policy bundle');
}
```

Three fields are worth putting on a dashboard: `signature.status`, `bundleVersion` and `loadedAt`. A `loadedAt` that stops advancing across a fleet means reloads are failing everywhere — which `onError` will also be telling you.

### Design the target namespace deliberately

Target matching is **exact and case-sensitive** on all three parts. There are no wildcards and no hierarchy, so `service: 'api'` does not match `service: 'API'` and `resource: 'invoices'` does not cover `resource: 'invoices/draft'`.

Pick a convention early and centralise it:

```ts
// targets.ts — one place, so a rename is one edit
export const TARGETS = {
  invoiceCreate: { service: 'api', resource: 'invoices', action: 'create' },
  invoiceRead:   { service: 'api', resource: 'invoices', action: 'read' },
  login:         { service: 'auth', resource: 'login', action: 'authenticate' },
} as const;

govplane.evaluate({ target: TARGETS.invoiceCreate, context });
```

Inline target literals scattered across a codebase are the most common source of a silent `{ decision: 'deny', reason: 'default' }` with no `policyKey` — a typo that no compiler catches.

`govplane analyze` discovers these call sites for you; keeping them in one module makes its output stable.

### Keep context flat, small and non-personal

```ts
// Good
context: { plan: 'enterprise', country: 'ES', isAuthenticated: true, failedAttempts: 2 }

// Avoid
context: { user: fullUserRecordFromDatabase }
```

Three reasons, in order of how quickly they bite:

1. **Context validation rejects nested objects** once you enable it. Passing whole entities works until the day you pin a `contextPolicy`, and then every call throws.
2. **PII does not belong in a decision input.** It is the easiest place for an email address to end up by accident. `blockLikelyPiiKeys` catches the obvious names; it cannot catch `identifier` holding a passport number.
3. **Conditions read a handful of keys.** Everything else is cost with no effect on the answer.

Pass a derived value rather than a raw one — `plan`, not the subscription record; `failedAttempts`, not the login history.

### Enable context validation, in development first

```ts
engine: {
  contextPolicy: { allowedKeys: ['plan', 'country', 'isAuthenticated', 'failedAttempts'] },
}
```

Pinning your real key set turns a silently-ignored typo (`isAuthenticatd`) into a loud failure. It also enables the PII heuristic.

{% hint style="warning" %}
Turn it on in development and run your test suite before shipping it. Enabling validation on an existing application is a **breaking change** for any call site passing nested context or an unlisted key — and it throws at evaluation time, on the request path.
{% endhint %}

### Never ignore a throttle

```ts
// Wrong: throttle silently becomes "allowed"
if (govplane.evaluate({ target, context }, { asBoolean: true })) proceed();
```

`asBoolean` maps `throttle` to `false` precisely so this fails closed rather than open. But a boolean cannot carry the limit, so a call site that acts on throttles must read the full decision:

```ts
const decision = govplane.evaluate({ target, context });
if (decision.decision === 'throttle') {
  await rateLimiter.consume(decision.throttle);   // limit, windowSeconds, key
}
```

Use `asBoolean` only where the policy genuinely produces just `allow` and `deny`.

### Prefer targets; reach for rule IDs knowingly

Evaluating by `target` is the portable form: it survives rules being renamed, split or reprioritised, because it asks a question about *what is being attempted*.

Evaluating by `ruleId` asks about a specific rule, which is right when the call site genuinely means that rule — but it couples your code to a bundle identifier, and it **throws** if the rule is removed.

```ts
// Always name the policy too — rule IDs are not unique across a bundle
govplane.evaluate({ ruleId: 'deny-large', policyKey: 'refund-control', context });
```

If you use rule IDs, add a startup assertion so a removed rule fails at boot rather than in a request:

```ts
const REQUIRED_RULES = [{ ruleId: 'deny-large', policyKey: 'refund-control' }];
for (const rule of REQUIRED_RULES) {
  govplane.evaluate({ ...rule, context: {} });   // throws RuleNotFoundError if absent
}
```

### Trace at `errors` in production

```ts
trace: {
  defaults: { level: 'errors' },
  onDecisionTrace: (event) => logger.info({ govplane: event }, 'decision'),
}
```

`errors` traces only `deny` and `kill_switch`, which is what you want to explain after the fact, at a fraction of the volume. Reserve `full` for debugging a specific decision with `force: true`.

If your sink does I/O, use `onDecisionTraceAsync` so it stays off the evaluation path. Note that draining it on shutdown requires `flushTraces()`, which lives on the **engine** and not on the client — so if that matters, build the engine yourself with `createPolicyEngine` and keep the reference.

Traces carry no context values, so they can go to a general-purpose log aggregator without a privacy review.

### Test against a real bundle

The engine is exported precisely so tests do not need a client, a file or a key:

```ts
import { createPolicyEngine } from '@govplane/runtime-sdk';
import bundle from './fixtures/policy-bundle.json';

const engine = createPolicyEngine({
  getBundle: () => bundle,
  validateContext: false,   // the engine's own default enforces a sample key list
});

it('denies after five failed attempts', () => {
  expect(engine.evaluate({ target: TARGETS.login, context: { failedAttempts: 6 } }))
    .toEqual({
      decision: 'deny',
      reason: 'rule',
      policyKey: 'login-protection',
      ruleId: 'deny-after-five-failures',
    });
});
```

Use the **real** bundle your application ships, not a hand-written stub — the point is to catch a policy change that breaks an assumption in your code.

`govplane simulate` runs this same engine from the command line, so a decision you reproduce in CI matches what the CLI reports.

### Ship the bundle as an artifact

Treat `policy-bundle.json` like any other build output: version it, sign it, and put it in the image or mount it. Then decide how it updates.

| Approach                              | Update mechanism       | Good for                                |
| ------------------------------------- | ---------------------- | --------------------------------------- |
| Baked into the image                  | Redeploy               | Strong change control, slow response    |
| Mounted (ConfigMap, volume) + `watch` | File replaced in place | Fast response without a deploy          |
| `createRemoteClient`                  | Polling                | Central management across many services |

Whichever you choose, `onError` should be wired to a metric. A reload that silently fails leaves you serving an old policy while believing you shipped a new one:

```ts
onError: (error) => {
  logger.error({ error }, 'govplane reload failed — serving previous bundle');
  metrics.increment('govplane.reload.failure');
},
```

### Pin the major version

```json
{ "dependencies": { "@govplane/runtime-sdk": "^2.1.0" } }
```

The SDK follows semantic versioning; 2.x will not change a decision a 2.0 bundle produces. A major version may, and a policy engine is not a dependency to upgrade unattended.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://govplane.gitbook.io/docs/documentation/sdk-for-javascript-node.js/best-practices.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
