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.
Three files
Section titled “Three files”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.
Test, review, apply
Section titled “Test, review, apply”alter policy validate --manifest @policy.json --bindings @bindings.jsonalter policy test --manifest @policy.json --bindings @bindings.json --tests @tests.jsonalter policy plan --manifest @policy.json --bindings @bindings.json --tests @tests.json --save reviewed-plan.jsonalter policy show-plan --app <app-id> --plan-id <plan-id>alter policy apply --plan @reviewed-plan.jsonplan 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.
Ownership is explicit
Section titled “Ownership is explicit”The pair manager + rule key is stable across renames. Binding kinds are app,
provider, agent and grant. Provider bindings are OAuth provider-config
UUIDs, not provider names; 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 agents list and
alter grants list (or alter managed-secrets grants list), each with
--output json.
- A matching interactive rule is never silently adopted. Export only your
manager’s rules by default; use
--adopt-existingto produce explicit revision-boundbindings.adoptentries 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
rulesmeans 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
rulesand include its key inmanifest.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": []:
alter policy export --manifest @export-targets.json --bindings @bindings.jsonalter policy export --manifest @export-targets.json --bindings @bindings.json --adopt-existingExport 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.
Meaningful test coverage
Section titled “Meaningful test coverage”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 and the existing 100 independently authored rules per physical target. 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.
Protected CI
Section titled “Protected CI”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:
alter policy apply --plan @reviewed-plan.json --yesThe 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.
GitHub Actions example
Section titled “GitHub Actions example”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-planneeds no reviewer. PutALTER_POLICY_PLAN_PAT, a PAT with onlydashboard_app_policy:readanddashboard_app_policy:rules_create, only in this environment.policy-productionrequires an independent reviewer, with self-review and bypass disabled. PutALTER_POLICY_APPLY_PATonly 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 deploymenton: workflow_dispatch:permissions: contents: readconcurrency: group: policy-production cancel-in-progress: falseenv: 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 --yesThese 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.