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.
- Evaluate — Hypothetical check: “would this
modelId/providerType(and optional route context) pass all bound policies?” Returnsallowed+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 type | What it does | Example |
|---|---|---|
model_allowlist | Only these models can be resolved | Allow gpt-4o, claude-sonnet; block everything else |
model_denylist | These specific models are blocked | Block gpt-3.5-turbo |
provider_allowlist | Only these providers can be resolved | Allow openai and anthropic, block google |
provider_denylist | These specific providers are blocked | Block a provider with a compliance issue |
deprecated_model_block | Block models marked deprecated in the catalog | Prevent resolution to end-of-life models |
budget_cap | Cap total spend per period | Max $500/month per environment |
token_cap | Cap total tokens per period | Max 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.
- Type:
model_allowlist - Scope: Project (applies to all environments and routes in this project)
- 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. - 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.
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.
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.
/api/policies/* from the browser with a Gateway Key. Call from your backend (key in env) or use dashboard session.Evaluate response shape (200):
{
"data": {
"allowed": true,
"violations": []
}
}Each violation object has policyId, policyName, type, message.
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):
// 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.
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.
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)
- Config only: Policy + binding created via dashboard or API — you’ve proven the object exists; no runtime assertion yet.
- 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_capviolations. - End-to-end: Resolve rejects when caps are exceeded the same way as other policy failures (step skip or
policy_blockedif all steps hit the cap).estimated_coston logs only increases if you report usage; until then, token_cap may be easier to test than budget_cap.
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).
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 summaryviolations— array of objects withpolicyId,policyName,type,message
Example body:
{
"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).
// 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[].typeobserved. - Resolve tested: step skip when first step blocked, or
policy_blockedwhen all steps blocked. - App resolve client parses structured JSON on failure (not only
err.messagesubstring 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.