# @lit-protocol/flows-policy

The Flows **policy + approval engine**: a pure, deterministic "one-gate" evaluator
plus the intent-hashing, JWS-approval, step-up, and EVM-UserOp primitives the gate
is built from. No I/O, no clock, no DB — every input arrives as a fact, so the same
bytes produce the same verdict everywhere.

## Why this package exists (the two-host invariant)

A Flows fund-movement decision must be made in **two places** and they must agree:

1. **The Flows backend** runs `evaluate()` as an *advisory* pre-flight ("would a live
   run be allowed?") so the UI can show a verdict before anything signs.
2. **The CID-pinned Lit Action** runs the *authoritative* seat inside the TEE — it is
   the only host whose verdict can release a signature.

If those two ran different code, the preview would lie. So this package is the **single
source of truth**: the backend imports it directly, and the action **embeds the compiled
bytes** of a narrow entry (`src/action-userop.ts`) as an inlined IIFE (the TEE has no
module resolution at execution). A CI sync test fails if the committed action bundle
drifts from a fresh build. Same source → same compiled logic → identical verdicts.

This is also why **you** can build your own gated Action on top of these helpers and get
the exact policy semantics the platform enforces — see *Build your own gated Action* below.

## Install

```bash
npm install @lit-protocol/flows-policy
```

> Inside this monorepo the package is consumed via a `file:` link
> (`"@lit-protocol/flows-policy": "file:packages/policy"` in the root `package.json`),
> so changes are picked up without re-publishing. Run `npm run build` in
> `packages/policy/` (or `npm run build:packages` at the root) to refresh `dist/`.

## Quickstart — the one gate

`evaluate()` takes an **intent** (what wants to move), the org **policy** (the law), the
day's **spend** state, and the action's capability **manifest**, and returns a `Verdict`:
`allow` | `allow_with_notify` | `hold` | `deny`, each with a human-readable trace.

```ts
import { evaluate, type Intent, type PolicyDoc, type SpendState, type CapabilityManifest } from '@lit-protocol/flows-policy'

const NOW = '2026-06-22T12:00:00.000Z'

const policy: PolicyDoc = {
  schema: 'flows-policy-v1',
  orgId: 'org_1',
  version: 1,
  caps: { maxNotionalPerOrderUsd: 2_000, maxNotionalPerDayUsd: 8_000 },
  circuits: { withdrawalsAlwaysHold: true, holdAssurance: 'L2', holdTtlSec: 86_400, policyLoosening: 'hold' },
}

const intent: Intent = {
  schema: 'flows-intent-v1',
  intentId: 'int_1',
  orgId: 'org_1',
  initiator: { id: 'dev_1', class: 'human', role: 'OPERATOR' },
  verb: 'trade-spot',
  params: { symbol: 'BTC/USDT', side: 'buy', qty: 0.01 },
  notionalUsd: 1_000,
  symbol: 'BTC/USDT',
  reason: 'rebalance',
  mode: 'live',
  createdAt: NOW,
}

const spend: SpendState = { schema: 'flows-spend-state-v1', date: '2026-06-22', orgDayUsd: 0 }
const manifest: CapabilityManifest = { schema: 'flows-capability-manifest-v1', verbs: ['trade-spot'] }

const verdict = evaluate({ intent, policy, policyHash: 'sha256-of-jcs(policy)', spend, manifest, now: NOW })
console.log(verdict.kind)   // 'allow'  (a $1,000 trade under a $2,000/order, $8,000/day cap)
```

Push the same intent to `notionalUsd: 5_000` and the verdict becomes `hold` (over the
per-order cap → needs a second person); switch `initiator.class` to `'agent'` with no
agent allowance and it `deny`s as `initiator.agent.unleashed`. See
`examples/policy-gated-action/` for a runnable version of all three.

### Verifying the policy hash

The gate trusts the policy doc you hand it; the *host* is responsible for proving that doc
is the registered one. Compute the canonical hash with the same helper the registry uses:

```ts
import { sha256Jcs } from '@lit-protocol/flows-policy'
const policyHash = sha256Jcs(policy)   // sha256 over RFC 8785 (JCS) canonical JSON
```

## Public surface

| Export | What it does |
| --- | --- |
| `evaluate(input)` | The one-gate decision. Pure; returns a `Verdict`. |
| `classifyPolicyEdit(from, to)` | `'tighten'` vs `'loosen'` — drives instant-apply vs hold/timelock. |
| `verifyApproval(opts)` | Email-approval-v2 JWS gate (assurance L1–L3). |
| `sha256Jcs(v)`, `intentHashFor(intent)`, `canonicalize(v)` | RFC 8785 canonicalization + the portable intent/policy hashing both hosts bind with. |
| `signJws`, `verifyJws`, `addressFromPrivateKey` | Detached-payload JWS used for approvals/attestations. |
| `verifyStepUp`, `signStepUpEoa`, `stepUpDigest` | Step-up (re-auth) proof verification. |
| `executionIdempotencyKey(...)` | Deterministic dedupe key for a single execution. |
| `gateYieldUserOp(...)`, `validateEvmYieldCalls(...)` | The EVM smart-account (ZeroDev Kernel) UserOp signing gate — the bytes the `sign-userop` action embeds. |
| `yieldUserIntentHash`, `yieldUserIntentChallenge`, `verifyYieldUserIntentProof` | Bind a yield UserOp to a signed user intent. |
| `computeUserOpHashV07`, `decodeKernelExecute`, `keccak256`, `functionSelector`, `decodeExecutionBatch`, `decodeExecutionSingle` | EVM/UserOp primitives the gate is built from. |
| `AAVE_V3_POOLS`, `ENTRY_POINT_V07` | Address constants. |

All types are exported alongside the functions (`Intent`, `PolicyDoc`, `Verdict`,
`SpendState`, `CapabilityManifest`, `GranteeAllowance`, `EvaluateInput`, …).

## Per-grantee leashes (standing authorizations)

Beyond the org-wide caps and the per-class `initiatorClassAllowances`, the gate enforces a
**named-grantee leash** when the host passes one. The host looks up an ACTIVE, non-expired
`StandingAuthorization` for the authenticated initiator (member / agent / API key) and
passes its bounds in as `granteeAllowance`, with `requireGranteeAllowance: true` to fail
closed if no grant matched:

```ts
const verdict = evaluate({
  intent, policy, policyHash, spend, manifest, now: NOW,
  requireGranteeAllowance: true,                       // this initiator MUST act under a grant
  granteeAllowance: {                                  // the matched grant's leash
    caps: { maxNotionalPerOrderUsd: 500, maxNotionalPerDayUsd: 500 },
    verbAllowlist: ['trade-spot'],
    venueAllowlist: ['binance'],
    spentTodayUsd: 0,
  },
})
```

The leash is enforced as a **most-restrictive bound** (∩ org ∩ install) — it can only
*narrow*, never widen. A defined-but-empty allowlist (`[]`) is deny-all on that axis (this
is how two disjoint grants intersect to deny-all rather than allow-all).

## Build your own gated Action

The TEE runs a single source blob and CIDs it — there is no `import` at execution time. To
reuse this engine inside your own Lit Action you **inline the compiled gate** as an IIFE,
exactly like `lit-actions/wallet/sign-userop.js` does:

1. Add (or reuse) a **narrow entry** under `packages/policy/src/` that re-exports only the
   gate functions your action needs — see `action-userop.ts`. Keeping it narrow lets
   esbuild tree-shake the rest (no `@noble/curves`, no `jwt`/`approval`) so the action
   stays small.
2. Bundle it to an IIFE that exposes a `FlowsPolicy` global and inline it into a thin shim
   at a `// __FLOWS_POLICY_IIFE__` marker. The repo's `scripts/build-wallet-actions.mjs`
   does this; `packages/policy/scripts/bundle.mjs` emits a generic
   `dist/flows-policy.iife.js` (+ ESM) with an SRI hash for the CDN-import path.
3. In your action, call the global: `const verdict = FlowsPolicy.evaluate({ ... })` and only
   sign when `verdict.kind === 'allow'`.

Because the action embeds the *same* bytes the backend imports, your pre-flight preview and
your in-TEE seat can never disagree.

## Scripts

```bash
npm run build       # tsc → dist/ (the importable package)
npm run bundle      # esbuild → dist/flows-policy.iife.js + .mjs (+ SRI hashes)
npm test            # vitest (evaluate, approval, stepup, userOp, idempotency, canonicalize, seat)
npm run typecheck   # tsc --noEmit
```

## Design notes

- **Pure & deterministic.** No network, no `Date.now()` in the decision path — `now` is an
  input. This is what lets the verdict be reproduced and attested.
- **Fail closed.** Unpriceable notional, a missing required grant, an unverifiable axis, or
  an unavailable screening all resolve to `deny`/`hold`, never a silent allow.
- **Intersection semantics.** The manifest, org policy, install bounds, and grantee leash
  all *narrow*; nothing a host passes can widen what the policy permits.

License: MIT.
