Skip to content

Guides

Manage policies in code

Review portable policy changes with explicit bindings, before/after tests and saved plans.

Use files when a restriction should be reviewed in Git and applied through CI. Use the dashboard for interactive rules. Both write the same policy rows and use the same runtime evaluators; files do not introduce a second policy language.

Keep reusable rule definitions separate from environment-specific UUIDs and request examples. The following example blocks DELETE requests app-wide.

policy.json:

{
"format_version": 1,
"manager": "security",
"targets": { "app": { "kind": "app" } },
"rules": [{
"key": "block-delete",
"target": "app",
"rule_type": "json_match",
"name": "Protect deletions",
"enabled": true,
"rule_body": { "when": { "method": "DELETE" }, "effect": "deny" }
}]
}

bindings.json (replace the example UUID with your app ID):

{
"format_version": 1,
"app_id": "22222222-2222-4222-8222-222222222222",
"targets": { "app": "22222222-2222-4222-8222-222222222222" }
}

tests.json (replace the app ID; organization_id is optional and defaults to the organization the token belongs to):

{
"format_version": 1,
"cases": [{
"name": "Deleting is blocked",
"targets": ["app"],
"context": {
"principal": {
"app_id": "22222222-2222-4222-8222-222222222222",
"environment": "production"
},
"resource": {
"kind": "oauth_token",
"resource_id": "grant:example",
"provider_id": "google"
},
"method": "DELETE",
"at": "2026-09-23T12:00:00Z"
},
"before": { "effect": "permit" },
"after": {
"effect": "deny",
"reasons": ["rule_denied"],
"rule_outcomes": { "block-delete": "deny" }
}
}]
}

This is a first-create example on a target with no existing rules. before is evaluated against the server’s current rows, never inferred from Git. After the initial apply, update the assertions to reflect the actual current rules. Preserved operator/settings rows appear as observed-<rule-uuid> in fixture outcomes. alter policy test returns both actual sides for inspection; do not copy a result into an assertion without checking that it is the intended policy.

A rule’s target names one declared target. To apply the same rule in several places, list them in targets instead; the first entry is the rule’s home. List agents, users or groups the rule must not apply to in exclude. Every entry is a key from the manifest’s targets map, and each exception must be narrower than one of the rule’s targets: a group under an app, provider or managed secret; a user under one of those or a group; an agent under any of those or a user. An excluded group also needs the same identity provider a group-bound grant needs (membership changes reported in real time, with webhooks enabled); otherwise the plan is refused with group_exclusion_unsupported and the reason. A file that uses the singular target is read as a one-entry targets list, so existing files plan with no change.

{
"format_version": 1,
"manager": "security",
"targets": {
"app": { "kind": "app" },
"billing-secret": { "kind": "managed_secret" },
"maintenance-agent": { "kind": "agent" }
},
"rules": [{
"key": "block-delete",
"targets": ["app", "billing-secret"],
"exclude": ["maintenance-agent"],
"rule_type": "json_match",
"name": "Protect deletions",
"enabled": true,
"rule_body": { "when": { "method": "DELETE" }, "effect": "deny" }
}]
}

Bind billing-secret and maintenance-agent to their UUIDs in bindings.json the same way as app. The plan shows each added or removed target and exception as its own step, alongside rule creates, updates and deletes. Adding a target or removing an exception narrows the rule and needs :rules_create; removing a target or adding an exception widens it and needs :rules_delete, which the plan’s required_scopes lists. Export writes targets, and exclude when the rule has exceptions.

A rule on an identity-provider group, with an exception for its lead:

{
"format_version": 1,
"manager": "security",
"targets": {
"contractors": { "kind": "group" },
"contractor-lead": { "kind": "user" }
},
"rules": [{
"key": "contractors-read-only",
"targets": ["contractors"],
"exclude": ["contractor-lead"],
"rule_type": "restriction",
"name": "Contractors read-only",
"enabled": true,
"rule_body": { "allowed_methods": ["GET"] }
}]
}
Terminal window
alter policy validate --manifest @policy.json --bindings @bindings.json
alter policy test --manifest @policy.json --bindings @bindings.json --tests @tests.json
alter policy plan --manifest @policy.json --bindings @bindings.json --tests @tests.json --save reviewed-plan.json
alter policy show-plan --app <app-id> --plan-id <plan-id>
alter policy apply --plan @reviewed-plan.json

plan refuses failing tests or uncovered changes. It prints complete before/after definitions and drift, and saves a new private file without overwriting an existing one. apply retrieves that exact server-held plan, compares its digest and asks for confirmation. Noninteractive execution requires --yes; it does not generate a fresh plan or silently accept drift. JSON goes to stdout; human review/warnings go to stderr.

Capture the exact plan_id field from plan’s JSON for show-plan --plan-id. The saved file includes app_id, plan_id and digest; keep that whole artifact for apply. All file commands support --output json|jsonl|table. The plan is written to --save before anything is rendered; if the local save fails, the command exits 1, emits the new artifact on stdout and names the recovery command on stderr. It never overwrites an existing file. test exits 1 on failed fixtures or uncovered keys, and 0 on passing evidence. The human review lists fixture pass/fail and uncovered keys.

validate, export and test need dashboard_app_policy:read. plan also needs dashboard_app_policy:rules_create, because it saves a reviewed artifact that a later apply executes. Planning reads a consistent policy snapshot and saves a temporary review; it does not create a manager, lock live policies, or contact your identity directory. The response explicitly marks approver_verification: "pending_apply". Apply checks approvers against the current directory before opening the policy write transaction; passing fixtures alone never establish approver eligibility. A rule can change while you plan: the saved review remains readable, but applying it will refuse stale state. After apply, the receipt and saved-plan response report checked_at_apply and retain any warnings returned by that check; this is not a promise of future directory eligibility.

Each organization can retain 100 plans across all apps, including applied receipts, and each planning identity can hold 20 unexpired plans within that. Inputs are limited to 1 MiB and the saved review/result to 4 MiB. This supports roughly four reviews an hour over the 24-hour approval window; it is a storage budget, not an API rate limit. New saves reclaim expired artifacts and return 429 policy_plan_storage_full with Retry-After when still full; the response’s scope says whether the organization or your own budget is full. Existing unexpired reviews are never evicted. Read or apply an existing plan, or wait until the indicated expiry before saving a new one. Saves in one organization are admitted one at a time; a save that cannot be admitted in time returns 409 policy_plan_busy, which the CLI exits with 75. Nothing was saved, so retry the same plan unchanged, with backoff. test and plan are also rate limited per identity to 30 requests per 5 minutes and answer 429 rate_limit_exceeded with Retry-After beyond that.

The plan expires after 24 hours, allowing a working-day approval handoff while bounding review-data retention. All revisions and current permissions are still checked on apply. Changes, manager baseline, replay result and success audit commit together. A retry of the same plan returns the same committed result without a second write; an applied plan stays replayable for 24 hours after its commit even once its review lifetime has passed. Concurrent successful applies return that same committed result. If state changed before apply, regenerate, retest and review. An unapplied plan past its lifetime returns 410 policy_plan_expired; generate a new one. Lock contention returns 409 policy_plan_busy, which the CLI exits with 75: nothing was applied, so retry the same apply unchanged, with backoff, without re-planning or editing the saved file. Stale state returns 409 policy_plan_stale with a reason (bound_rules_changed, preserved_layers_changed for a personal rule on a bound target, or ownership_changed), which the CLI prints after the remedy. policy_plan_digest_mismatch identifies a modified or different saved artifact; the CLI checks the digest before sending the apply. Each refusal’s CLI exit code is listed in the policy command reference. These refusals retain the plan identity and specific reason in the audit history.

The pair manager + rule key is stable across renames. Binding kinds are app, provider, managed_secret, group, user, agent and grant. Provider bindings are OAuth provider-config UUIDs, not provider names; managed-secret bindings are managed secret UUIDs; group bindings are group UUIDs (any of the app’s groups, whether synced from the identity provider, imported from its directory, or created manually); user bindings are app-user UUIDs; grant bindings accept OAuth or managed-secret grant UUIDs. Every bound object must belong to the specified app. Read the UUIDs from the id field of alter providers list, alter managed-secrets list, alter managed-secrets groups list, alter end-users list, alter agents list and alter grants list (or alter managed-secrets grants list), each with --output json. A user target manages only the application’s rules on that user; the limits the user set in the Wallet are never exported, adopted, or pruned.

  • A matching interactive rule is never silently adopted. Export only your manager’s rules by default; use --adopt-existing to produce explicit revision-bound bindings.adopt entries for eligible interactive operator rows.
  • Code-owned rules show their manager in the dashboard. Interactive edit, disable and delete return a managed-rule conflict, not a missing-rule error; they are blocked on the server, not merely hidden in the UI.
  • Omitting an owned key from rules means delete. Its before-state must be covered by tests. Other managers, interactive rules, settings-owned rules and personal rules are not pruned.
  • To return a rule to interactive management without deleting its restriction, omit it from rules and include its key in manifest.release. Review/apply that change with update permission. To change its type, review a replacement; both create and delete permissions are required.
  • Retain bindings for all currently managed targets, including removal/release targets. Drift against the last successfully applied baseline is shown explicitly.

For export, use a manifest containing the manager and targets with "rules": []:

Terminal window
alter policy export --manifest @export-targets.json --bindings @bindings.json
alter policy export --manifest @export-targets.json --bindings @bindings.json --adopt-existing

Export returns { "files": { "manifest": ..., "bindings": ... }, "skipped": [...] } and makes no ownership change. skipped lists every observed rule on the bound targets that was not exported, with a reason (other_manager, settings_owned, not_operator_authored, adoption_not_requested, or not_representable for a stored rule the current file format cannot express); the CLI prints the same list on stderr. Separate the two file objects into the input files and author before/after tests before planning adoption.

Each changed key, including a removed rule, must contribute to a passing before/after case. A predicate that never matches does not count as coverage. Disabled rules must be asserted as disabled. Runtime AI checks are asserted as runtime_review, not a fabricated allow/deny. Quota examples supply explicit context.quota_counts keyed by rule key; omitted counts remain indeterminate.

Supply real intended principal fields (agent_id, api_key_id, effective_scopes), resource/provider, method, endpoint, client_ip and timezone-aware at. Content tests supply operation.operation_id, optional operation.operation_id_aliases and operation.families, and projected params (strings, numbers, booleans, or arrays of strings for set-valued parameters such as recipients). Parameter fixtures require a cataloged provider operation, so rule operands use the same provider-specific normalization as enforcement. Values in params are already-projected canonical values, not a raw HTTP body. Expectations compare effect, reasons, per-rule outcomes and complete obligations including approval/redaction/sign-in parameters. Approval cannot override a deny. targets selects the declared layers in that fixture; it does not attest that a live request owns or will load them.

Portable input envelopes are limited to 1 MiB, with up to 1,000 rules/targets per manager, the existing 100 rules per physical target (counting every rule attached there, not only those whose home it is), and 100 targets and exceptions per rule. A rule already at 100 can still be re-pointed: when an exception you keep rests only on the target you are removing, the plan swaps the old target for the new one in a single step, so the apply never passes through a state the limit or the exception rules would refuse. A swap still removes a target, so its plan needs :rules_delete as well as :rules_create, and the plan review prints both halves of the step: applies at the new target, then no longer applies at the one it replaces. A deprovisioned user keeps the rules a file set on them: the file can still edit, delete, or release those rules, but a new rule, a rule-type change, or a new attachment or exclusion naming a deprovisioned user is refused at plan time with the reason. Observed review sets are also bounded at 1,000 rows, never silently truncated. Saved plans allow 4 MiB for before/after data. Split larger deployments into explicitly named managers/target sets. These are cold-path limits, not a runtime authorization budget.

Tests also have an evaluation budget across both sides of all fixtures: 10,000 applicable rule visits and 4 MiB of rule-body text (multiplied by projected parameter text for parameter conditions). A fixture suite over budget is rejected before evaluation with an evaluation-budget error and creates no plan; simplify repetitive rules or split the suite. These bounds protect API responsiveness; they do not change live policy enforcement.

Run validation/tests/plan from the reviewed commit with a PAT that holds only dashboard_app_policy:read and dashboard_app_policy:rules_create (plan saves a reviewed artifact, so it needs the create scope), never the update or delete scopes. Save the plan as a private, short-retention CI artifact. Approvers must see its semantic review and test results. After approval, a protected job downloads that same artifact and runs:

Terminal window
alter policy apply --plan @reviewed-plan.json --yes

The protected apply job receives a PAT with dashboard_app_policy:read and the plan’s exact required_scopes: :rules_create, :rules_update and/or :rules_delete. Only that job holds the widening update and delete scopes. Because the plan PAT could itself apply a plan that only creates rules, never run apply with it. Widening scopes must be explicit; a wildcard does not grant them. Do not pass a privileged token to untrusted pull-request code, regenerate the plan after approval, or publish saved plans. Fixtures/reviews may contain sensitive policy conditions and approver addresses; use synthetic examples and never put credentials or real request payloads in Git. Prefer running the trusted CLI in a protected job without executing repository code from an untrusted branch.

In a private repository, put the three files above in policy/ and save this workflow under .github/workflows/policy.yml. Set repository variable ALTER_CLI_VERSION (an exact released version supporting these commands, never latest). Before enabling this workflow, create two environments and restrict each one’s deployment branches to main:

  • policy-plan needs no reviewer. Put ALTER_POLICY_PLAN_PAT, a PAT with only dashboard_app_policy:read and dashboard_app_policy:rules_create, only in this environment.
  • policy-production requires an independent reviewer, with self-review and bypass disabled. Put ALTER_POLICY_APPLY_PAT only in this environment.

Never store either PAT as a repository secret. A workflow on any branch of the repository can read a repository secret, and a job-level if: does not protect it: it restricts only the workflow that contains it. The plan PAT alone can apply a plan that only creates rules, so a plan PAT readable from other branches could change live policy without the reviewed apply. Confirm your GitHub plan supports these protections for private repositories; a newly named environment alone is not a gate.

The plan job runs only on the protected default branch and checks out only data, not executable project scripts. Review the private artifact’s definitions, tests, digest and required scopes before approving the apply job. This follows GitHub’s environment-secret gating and the saved-plan separation used by Terraform. Artifacts last one day; approval after the server’s 24-hour expiry fails closed.

name: Reviewed policy deployment
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: policy-production
cancel-in-progress: false
env:
ALTER_CLI_VERSION: ${{ vars.ALTER_CLI_VERSION }}
jobs:
plan:
if: github.ref == 'refs/heads/main' && github.event.repository.private
runs-on: ubuntu-latest
timeout-minutes: 10
environment: policy-plan
outputs:
artifact_id: ${{ steps.upload.outputs.artifact-id }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: policy
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24.5.0'
package-manager-cache: false
- name: Install pinned trusted CLI (no repository scripts)
run: |
[[ "$ALTER_CLI_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
npm install --global --ignore-scripts "@alter-ai/cli@$ALTER_CLI_VERSION"
- name: Validate, test and save the semantic plan
env:
ALTER_PAT: ${{ secrets.ALTER_POLICY_PLAN_PAT }}
run: |
alter policy validate --manifest @policy/policy.json --bindings @policy/bindings.json
alter policy test --manifest @policy/policy.json --bindings @policy/bindings.json --tests @policy/tests.json
alter policy plan --manifest @policy/policy.json --bindings @policy/bindings.json --tests @policy/tests.json --save reviewed-plan.json
- id: upload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: reviewed-policy-${{ github.run_id }}-${{ github.run_attempt }}
path: reviewed-plan.json
retention-days: 1
if-no-files-found: error
apply:
needs: plan
runs-on: ubuntu-latest
timeout-minutes: 10
environment: policy-production
steps:
# No checkout: the privileged job never runs repository code.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24.5.0'
package-manager-cache: false
- name: Install the same pinned trusted CLI
run: |
[[ "$ALTER_CLI_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
npm install --global --ignore-scripts "@alter-ai/cli@$ALTER_CLI_VERSION"
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
artifact-ids: ${{ needs.plan.outputs.artifact_id }}
merge-multiple: true
- name: Apply exactly the reviewed server plan
env:
ALTER_PAT: ${{ secrets.ALTER_POLICY_APPLY_PAT }}
run: alter policy apply --plan @reviewed-plan.json --yes

These are developer/PAT management APIs. Runtime Python and TypeScript SDK keys cannot apply policy files. Existing SDK calls enforce the resulting canonical rows identically; no new runtime SDK method or key scope is needed.

Report an issue with this page

Necessary

Required for sign-in, security, authorization, and remembering your choices.

Always active

Analytics

Helps us understand which product and documentation features are useful.

Performance diagnostics

Uses performance tracing and privacy-masked session replay to diagnose problems.

You can change these choices at any time from Cookie settings.