> 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/evaluating-decisions.md).

# Evaluating Decisions

## Evaluating Decisions

`evaluate()` is the whole API surface most applications use. It is **synchronous**, runs **in-process**, and takes microseconds — there is no `await`, no network call and no shared state.

```ts
const decision = govplane.evaluate({
  target:  { service: 'api', resource: 'invoices', action: 'create' },
  context: { plan: 'enterprise', isAuthenticated: true },
});
```

The semantics — precedence, defaults, scoping — are the same in every Govplane SDK and are described in Evaluation. This page is the Node.js signature.

### What you pass

Two forms, discriminated by which key is present:

```ts
type PolicyEngineEvaluateInput =
  | { target: Target;  context?: Record<string, unknown> }
  | { ruleId: string;  policyKey?: string; context?: Record<string, unknown> };

interface Target { service: string; resource: string; action: string }
```

`target` selects rules by exact string match on all three parts — no wildcards, no hierarchy. `context` is what rule conditions read; a rule with no `when` never looks at it.

### What you get back

```ts
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 };
```

It is a **discriminated union**, so narrowing on `decision` gives you the descriptor without a cast:

```ts
if (decision.decision === 'throttle') {
  decision.throttle.limit;      // typed
}
```

`reason` says how the answer was reached:

| `reason`    | Meaning                                             | `ruleId` |
| ----------- | --------------------------------------------------- | -------- |
| `'rule'`    | A rule matched and fired                            | present  |
| `'default'` | A policy default applied, or nothing matched at all | absent   |

```ts
{ decision: 'allow', reason: 'default', policyKey: 'login-protection' }  // that policy's default
{ decision: 'deny',  reason: 'default' }                                // nothing covered the target
```

A `default` decision **with** a `policyKey` came from that policy. One **without** is the global deny-by-default.

### Handling every outcome

A decision is a signal; acting on it is your application's job.

```ts
const decision = govplane.evaluate({ target, context });

switch (decision.decision) {
  case 'allow':
    break;

  case 'deny':
    return reply.status(403).send({ error: 'Forbidden', policy: decision.policyKey });

  case 'kill_switch':
    logger.error({ service: decision.killSwitch.service }, 'kill switch active');
    return reply.status(503).send({
      error:  'Service Unavailable',
      reason: decision.killSwitch.reason,
    });

  case 'throttle':
    // The SDK signals intent and its parameters. Enforcement is yours.
    if (!(await rateLimiter.consume(decision.throttle))) {
      return reply
        .header('Retry-After', String(decision.throttle.windowSeconds))
        .status(429)
        .send({ error: 'Too Many Requests', limit: decision.throttle.limit });
    }
    break;

  case 'custom':
    applyVariant(decision.parsedValue ?? JSON.parse(decision.value));
    break;
}
```

{% hint style="warning" %}
`throttle` does **not** rate-limit anything. The SDK tells you the limit, the window and the bucketing key; your infrastructure counts requests. A `throttle` decision you ignore is an unlimited request.
{% endhint %}

See Effects for each type in detail.

### Shorter return shapes

Call sites that only branch on the outcome can ask for it directly:

```ts
if (!govplane.evaluate({ target, context }, { asBoolean: true })) return forbidden();

const allowed = govplane.evaluate({ target, context }, { asBinary: true });   // 1 | 0
const effect  = govplane.evaluate({ target, context }, { effectOnly: true }); // 'allow' | 'deny' | …
```

The overloads make the return type follow the option, so none of these needs narrowing:

```ts
evaluate(input): Decision;
evaluate(input, options: { asBoolean: true }): boolean;
evaluate(input, options: { asBinary: true }): 0 | 1;
evaluate(input, options: { effectOnly: true }): Decision['decision'];
```

| Decision      | `asBoolean` | `asBinary` |
| ------------- | ----------- | ---------- |
| `allow`       | `true`      | `1`        |
| `custom`      | `true`      | `1`        |
| `deny`        | `false`     | `0`        |
| `kill_switch` | `false`     | `0`        |
| `throttle`    | **`false`** | **`0`**    |

{% hint style="warning" %}
`throttle` reduces to `false` even though it permits the action. A boolean cannot carry the limit, so a caller who saw `true` would proceed at an unbounded rate — failing open on the one effect whose purpose is to bound it.

**If you act on throttles, read the full decision.**
{% endhint %}

All three shapes are lossy by design: a kill switch's service, a custom effect's value and a throttle's window exist only on the full object.

Set **at most one**. Setting two throws `ConfigurationError` with code `INVALID_EVALUATE_OPTIONS` rather than picking one for you.

```ts
govplane.evaluate({ target }, { asBoolean: true, asBinary: true });
// ConfigurationError: Only one of asBoolean, asBinary or effectOnly may be set; received asBoolean, asBinary.
```

`evaluateWithTrace` ignores these options and always returns the full decision — a trace with nothing to explain is not worth having.

### Evaluating a single rule

A rule can be addressed by name instead of by target:

```ts
govplane.evaluate({ ruleId: 'deny-after-five-failures', context: { failedAttempts: 6 } });
```

This is for call sites that already know which rule they mean, where restating the service, resource and action is repetition that can drift from the bundle. Target matching is skipped entirely — the rule's own target says what it covers, not whether it was selected — but `context` still matters, because the rule's condition reads it.

**Both ways of asking give the same answer.** If the rule does not fire, its owning policy's default applies, exactly as it would had you named the target:

```ts
// deny-after-five-failures fires at >= 5; its policy allows by default
govplane.evaluate({ ruleId: 'deny-after-five-failures', context: { failedAttempts: 3 } });
// → { decision: 'allow', reason: 'default', policyKey: 'login-protection' }
```

#### Ambiguity

Rule IDs are unique **within a policy**, not across a bundle. Two policies may each contain a rule called `deny-large`, and such a bundle is valid. Given only a `ruleId`, the **first match in bundle order** wins. Name the policy to remove the doubt:

```ts
govplane.evaluate({ ruleId: 'deny-large', policyKey: 'refund-control', context });
```

#### It throws

If the rule — or the named policy — is not in the bundle, this throws `RuleNotFoundError` rather than returning a denial:

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

try {
  govplane.evaluate({ ruleId: 'typo-here' });
} catch (error) {
  if (error instanceof RuleNotFoundError) {
    // error.code === 'RULE_NOT_FOUND'
    // error.details === { ruleId: 'typo-here' }
  }
}
```

{% hint style="info" %}
This is the one evaluation input the engine refuses to answer with a denial. Everything else degrades to deny — that is the safe direction. But naming a rule that does not exist is a mistake in the *calling code*, and reporting it as `deny` would leave a typo looking like a policy decision nobody investigates.

`__default__` is rejected for the same reason: it is the engine's internal marker for a policy default, not a rule you can evaluate.
{% endhint %}

### How the winner is chosen

Every rule that matches the target and passes its condition contributes a candidate. Then:

1. **Effect precedence** — `kill_switch > deny > throttle > allow > custom`
2. **Priority**, descending, within the winning effect type
3. **`policyKey`, then `ruleId`**, alphabetically

Precedence beats priority: a `kill_switch` at priority 1 wins over a `deny` at 100.

Among throttles the **strictest** wins — the lowest `limit ÷ windowSeconds` — not the highest priority.

Policy defaults compete as synthetic rules at `priority: -1`, so an explicit rule always wins over a default. If nothing at all applies, the answer is `{ decision: 'deny', reason: 'default' }`.

### Tracing a decision

When an answer is not what you expected, ask for a trace rather than guessing:

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

const result = govplane.evaluateWithTrace(
  { target, context },
  { level: 'full', force: true },
);

if (result.trace) {
  console.log(formatTrace(result.trace, { multiline: true, includeDiscarded: true }));
}
```

```
[govplane] traceId=ed1d1cb9-… sampled=forced policies=1 rules=1 matched=0
winner → policy=login-protection rule=__default__ effect=allow priority=-1
discarded rules:
- policy=login-protection rule=deny-after-five-failures effect=deny reason=when_false
```

`includeDiscarded` is the important part — without it you see the winner but not what was ruled out, or why:

| `discardedReason` | Meaning                                                              |
| ----------------- | -------------------------------------------------------------------- |
| `disabled`        | `status` is not `active`                                             |
| `target_mismatch` | The rule aims somewhere else                                         |
| `when_false`      | Target matched; the condition did not, and there was no `elseEffect` |
| `invalid_effect`  | The effect is malformed                                              |

Two markers to recognise: `rule=__default__` and `priority=-1` both mean the decision came from a policy default.

{% hint style="warning" %}
**`result.trace` is optional.** `evaluateWithTrace` returns `Decision & { trace?: … }`, and the trace is present only when the configured level, sampling and budget allow it. Always guard before reading it. `force: true` bypasses sampling and the budget, which is what you want when reproducing one decision.
{% endhint %}

`level: 'full'` includes the discarded rule list; `errors` and `sampled` return a compact trace without it.

### Inspecting the bundle

```ts
govplane.bundle();     // the RuntimeBundle currently in force
govplane.status();     // origin, version, checksum, env, signature, policy count, loadedAt
await govplane.reload();   // re-read the source now, regardless of watch or poll interval
govplane.close();          // stop the watcher or poller
```

`reload()` returns the new `ClientStatus`, so it doubles as an operational probe:

```ts
app.post('/admin/policy/reload', async (req, reply) => {
  const status = await govplane.reload();
  return reply.send(status);
});
```

### Next

* [Errors](/docs/documentation/sdk-for-javascript-node.js/errors.md) — every error code and what to do about it
* [Best Practices](/docs/documentation/sdk-for-javascript-node.js/best-practices.md) — how to run this in production


---

# 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/evaluating-decisions.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.
