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.
One rule in several places
Section titled “One rule in several places”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"] } }]}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, 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-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, 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.
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.