Step 5 of 10

Phase 4 — Apply policies

Time: ~15 minutes
Prerequisites: Phase 3 complete (at least one route with steps configured, resolve returns route-aware results)
You'll need: Access to the Dashboard, your project from Phase 1

This phase adds guardrails around resolution. Policies constrain which models and providers can be returned, block deprecated models, and cap spend. By the end, your resolve calls are filtered through policies before returning a result, and you can test that policy violations are correctly rejected.

Terminology
  • Evaluate — Hypothetical check: “would this modelId / providerType (and optional route context) pass all bound policies?” Returns allowed + violations. Does not run route resolution.
  • Resolve — Route execution: picks the first enabled route step in order that passes the same policy engine. Mutually reinforcing with evaluate for the same context, but resolve walks real steps.
  • Policy binding — Links a policy to a target (workspace, project, environment, or route). Scope in the UI usually means which target you bind to.
  • Restormel step fallback — Next enabled step when the current step is blocked by policy. Not the same as app legacy fallback (your app’s non-Restormel routing when resolve fails).
  • policy_blocked — HTTP 403 from resolve when every enabled step fails policy checks; body includes violations.
Step 4.1 — Understand policy types

Policies are rules attached at the workspace, project, or environment level. They are evaluated during every resolve call. Resolve uses enabled-step order with policy filtering: it tries each enabled step in order and returns the first step that passes all policies. There is no implicit provider health probing. If a policy blocks a step, Restormel skips to the next step; if all steps are blocked, resolve returns a policy_blocked error (403) with violation details.

Policy typeWhat it doesExample
model_allowlistOnly these models can be resolvedAllow gpt-4o, claude-sonnet; block everything else
model_denylistThese specific models are blockedBlock gpt-3.5-turbo
provider_allowlistOnly these providers can be resolvedAllow openai and anthropic, block google
provider_denylistThese specific providers are blockedBlock a provider with a compliance issue
deprecated_model_blockBlock models marked deprecated in the catalogPrevent resolution to end-of-life models
budget_capCap total spend per periodMax $500/month per environment
token_capCap total tokens per periodMax 10M tokens/month

Policies stack. If you have both a model_allowlist and a budget_cap, both must pass for a model to be resolved.

Step 4.2 — Create a model allowlist

Start with the most common policy: restricting which models your app can use.

Dashboard — Your project → PoliciesCreate policy.
  1. Type: model_allowlist
  2. Scope: Project (applies to all environments and routes in this project)
  3. Models: Pick each ID from your live model catalog (Dashboard → Models or GET /api/models). Do not assume walkthrough example IDs exist in your deployment.
  4. Save the policy.

You'll see

The policy listed on the project's Policies page with the type, scope, and allowed models.

How to test

Call resolve; the response data.modelId comes from the first enabled step that passes policies. If your route steps include a blocked model, resolve skips that step and returns the next allowed step.

Tip — The resolve endpoint does not take an arbitrary model override today. To test allowlisting deterministically, set a route step's modelId to a blocked model, then confirm resolve skips it.

Call resolve with an allowed model in your route; expected: a modelId from your allowlist. The resolve API returns data.providerType as vertex when the step uses Google internally (policies still use google).

Step 4.3 — Add a deprecated-model block

This policy prevents your app from resolving to models that the Restormel model catalog has marked as approaching end-of-life. Even if a route step specifies a deprecated model, this policy blocks it and forces a fallback.

Dashboard — Your project → PoliciesCreate policy. Type: deprecated_model_block, Scope: Project. No additional configuration — the policy reads deprecation status from the model catalog automatically.

You'll see

The policy listed on the Policies page. Its effect depends on whether any models in your routes are actually deprecated in the catalog.

How to test

If you have a route step that specifies a model currently marked as deprecated in your catalog, resolve skips that step. If none are deprecated, test via evaluate using any deprecated ID that exists in your catalog, or skip until one exists.

Security — Never call /api/policies/* from the browser with a Gateway Key. Call from your backend (key in env) or use dashboard session.

Evaluate response shape (200):

json
{
  "data": {
    "allowed": true,
    "violations": []
  }
}

Each violation object has policyId, policyName, type, message.

bash
curl -s -X POST \
  "https://restormel.dev/keys/dashboard/api/policies/evaluate" \
  -H "Authorization: Bearer ${RESTORMEL_GATEWAY_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "projectId": "'${RESTORMEL_PROJECT_ID}'",
    "environmentId": "YOUR_ENV_ID",
    "modelId": "PICK_A_MODEL_ID_FROM_YOUR_CATALOG",
    "providerType": "openai"
  }' | jq '.data'

Minimal backend helper (server-only; key from process.env.RESTORMEL_GATEWAY_KEY):

ts
// Server-only. Load RESTORMEL_GATEWAY_KEY from env; never send to the browser.
const base = process.env.RESTORMEL_KEYS_BASE ?? 'https://restormel.dev';

export async function evaluatePoliciesRemote(input: {
  projectId: string;
  environmentId?: string;
  routeId?: string;
  modelId?: string;
  providerType?: string;
}) {
  const key = process.env.RESTORMEL_GATEWAY_KEY;
  if (!key) throw new Error('RESTORMEL_GATEWAY_KEY is not set');
  const res = await fetch(
    `${base}/keys/dashboard/api/policies/evaluate`,
    {
      method: 'POST',
      headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
      body: JSON.stringify(input),
    }
  );
  const json = (await res.json()) as {
    data?: { allowed: boolean; violations: { policyId: string; policyName: string; type: string; message: string }[] };
    error?: string;
  };
  if (!res.ok) throw new Error(json.error ?? `evaluate HTTP ${res.status}`);
  return json.data!; // { allowed, violations }
}

// Assertions in tests: expect(allowed).toBe(false); expect(violations[0].type).toBe('model_allowlist');

Expected: data.allowed is false when blocked; inspect violations[].type.

Tip — The evaluate endpoint is useful for testing policies without actually resolving. It answers "would this model/provider combination pass all policies?" without executing a full route evaluation.
Step 4.4 — Add a budget cap

budget_cap / token_cap compare usage in request_logs for the bound scope over the current calendar month. Resolve and evaluate both enforce them when policies are bound.

Dashboard — Create policy type budget_cap, bind to environment. Rule uses numeric limit (monthly ceiling on summed estimated_cost in request_logs). token_cap sums input+output tokens the same way.

Success criteria (pick what matches your setup)

  1. Config only: Policy + binding created via dashboard or API — you’ve proven the object exists; no runtime assertion yet.
  2. Evaluate shape: With usage under cap, evaluate returns allowed; after synthetic over-cap usage in logs (or very low limit), evaluate returns budget_cap / token_cap violations.
  3. End-to-end: Resolve rejects when caps are exceeded the same way as other policy failures (step skip or policy_blocked if all steps hit the cap). estimated_cost on logs only increases if you report usage; until then, token_cap may be easier to test than budget_cap.
Operational clarity — “Policy created” ≠ “spend is tracked.” Without usage reporting, budget caps mostly reflect resolution-time log rows (often zero cost). Prefer token_cap for deterministic tests, or accept config-level validation until reporting is wired.
Step 4.5 — Test policy stacking

Policies stack: all active policies must pass for a model to be resolved. Verify by having both a model_allowlist and a deprecated_model_block active, then evaluating a model that is on the allowlist but deprecated (if one exists).

bash
curl -s -X POST \
  "https://restormel.dev/keys/dashboard/api/policies/evaluate" \
  -H "Authorization: Bearer ${RESTORMEL_GATEWAY_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "projectId": "'${RESTORMEL_PROJECT_ID}'",
    "environmentId": "YOUR_ENV_ID",
    "modelId": "MODEL_ON_ALLOWLIST",
    "providerType": "openai"
  }' | jq '.data'

Expected: Allowed model + provider → allowed: true. Same flow with a model on the allowlist but deprecated in your catalog → allowed: false, violations[].type includes deprecated_model_block. Use IDs that exist in your catalog.

Step 4.6 — Handle policy errors in your resolve wrapper

When all route steps are blocked by policies, resolve returns HTTP 403 with a JSON body (not necessarily thrown as Error with that text). Primary contract:

  • error — e.g. "policy_blocked"
  • message — short human-readable summary
  • violations — array of objects with policyId, policyName, type, message

Example body:

json
{
  "error": "policy_blocked",
  "message": "All route steps were blocked by policy",
  "violations": [
    {
      "policyId": "…",
      "policyName": "…",
      "type": "model_allowlist",
      "message": "…"
    }
  ]
}

Parse response.json(), branch on error and violations[].type. Use violation detail only in server logs; map to safe user-facing copy in your app. Same pattern for 402 usage_limit_reached and 404 no_route (different shapes — see Phase 2).

ts
// After fetch to /resolve — always parse JSON on non-2xx; do not classify from err.message alone.
const res = await fetch(resolveUrl, { method: 'POST', headers, body: JSON.stringify({ environmentId, routeId }) });
const body = await res.json().catch(() => ({} as Record<string, unknown>));
if (!res.ok) {
  const code = typeof body.error === 'string' ? body.error : 'unknown';
  const violations = Array.isArray(body.violations) ? body.violations : [];
  if (res.status === 403 && code === 'policy_blocked') {
    // Classify by violations[].type: model_allowlist, budget_cap, token_cap, deprecated_model_block, …
    console.error('[restormel] policy_blocked', violations); // detail for logs; user-facing message separate
  }
  if (code === 'usage_limit_reached') { /* 402 + body.data.limit/used */ }
  if (code === 'no_route') { /* 404 */ }
  // App legacy fallback (non-Restormel routing) — not the same as Restormel route-step fallback
  return legacyResolve();
}

App legacy fallback (Phase 2) remains appropriate when resolve fails; that is separate from Restormel trying the next route step inside one resolve call.

Checkpoint checklist: mark each step complete as you finish it.

Checklist

Prompts for this phase

These are optional and collapsed by default. Use them if you're implementing Phase 4 with a coding agent.

Checkpoint

Phase 4 is complete only if all apply:

  • Policies created and bound at the intended scope (project/environment); binding targets documented.
  • Evaluate run from backend for at least one allowed and one blocked model/provider pair; violations[].type observed.
  • Resolve tested: step skip when first step blocked, or policy_blocked when all steps blocked.
  • App resolve client parses structured JSON on failure (not only err.message substring matching).
  • Optional: stacking or budget/token evidence per Step 4.4 criteria.

Dashboard: policy creation works; detailed ruleDefinition edits and bindings may still require the Policies API — see Prompt 4C. A future “test policy” UI would reduce curl-only workflows.