Skip to content

Policies & Governance

A reviewer catches a PCI-zoned service calling the public internet directly — this time. Next sprint a different reviewer misses it, or the change lands on a Friday with nobody watching. Architecture review enforced by a human’s attention is only as reliable as that attention, every time, forever.

A policy is a rule that lives next to the model instead of in a reviewer’s head. It’s a query over the architecture that yields findings — a node or an edge that violates the rule — and those findings fail the build: archlang check exits non-zero, the editor shows the finding as a diagnostic, and a view can paint it on the board. This is архревью-как-код, architecture review as code: a rule that bites because the model says so, not because someone remembered to look.

policy TeamOwnership {
"Every service names an owning team."
severity: error
forbid service and where (not @@team)
}

Read top to bottom: a description, a severity, one rule. Any service with no @@team aspect is a finding. Add this policy to a workspace with an unowned service and archlang check fails — not because a reviewer noticed, because the rule fired.

Policies are built on the same selector algebra as views: forbid/require take the same show/hide selectors, @@key reads the same aspect axis, where (…) evaluates the same expressions. If you can write a view that shows something, you can write a policy that forbids it.

A policy body is a header zone — an optional description string, then fields like severity: error — followed by rules, read top to bottom. This is the same header-zone shape a view’s body has (Chapter 8): the zone ends at the first rule, and everything after is evaluated in order.

policy #p001 PciIsolation {
"PCI workloads never talk to the public zone directly"
severity: error
forbid @@security.zone:"PCI" > @@security.zone:"Public"
}

The formatter mints the stable #p001 id on save, the same mechanism that stamps modules and processes (Chapter 13) — a renamed policy is detected as a rename, not delete-plus-add.

A policy declares at file top level (anywhere in a space’s subtree) or inside a module body; in <Module> policy … { } is the placed twin of the body form. Both shapes matter, and the difference is scope — covered below.

The rest of this chapter builds one running example. A checkout flow with a PCI-scoped payments service, a public gateway, and a third-party analytics vendor:

service Payments {
aspect team: "Payments"
aspect { domain: "Payments"; security.zone: "PCI" }
rest_create authorize
}
service Orders {
aspect team: "Commerce"
aspect { domain: "Commerce" }
rest_create createOrder
}
gateway WebGateway {
aspect team: "Platform"
aspect { security.zone: "Public" }
rest_create forward
}
external_system Analytics {
aspect team: "Payments"
aspect { security.zone: "Public" }
ext.vendor: "Segment"
rest_create track
}
service AuditLog {
aspect team: "Platform"
rest_create record
}
process Checkout {
Customer > WebGateway.forward
WebGateway > Orders.createOrder
Orders > Payments.authorize
Payments > Analytics.track
Orders > AuditLog.record "compliance trail"
Payments > AuditLog.record "compliance trail"
}

Payments.authorize calls out to Analytics.track for conversion tracking — a PCI-zoned service reaching a public-network vendor directly. That’s a real dependency the process proves, and it’s exactly the shape a policy should catch.

forbid <selector> yields one finding per matching element. The selector’s sort decides what’s flagged: a node-sort forbid flags nodes, an edge-sort forbid flags edges — anchored at the edge’s source endpoint.

// node-sort: every service missing an owner is a finding
policy TeamOwnership {
severity: error
forbid service and where (not @@team)
}
// edge-sort: every PCI→Public edge is a finding
policy PciEgressControl {
"PCI-zoned workloads never call a public-network module directly."
forbid @@security.zone:"PCI" > @@security.zone:"Public"
}

PciEgressControl fires on Payments > Analytics.trackPayments carries security.zone: "PCI", Analytics carries security.zone: "Public", and the process proves the edge between them.

A carrier-only forbid pairs with a coverage rule. When both arrow sides are aspect atoms, only carriers of that aspect match — an un-zoned middle hop launders the taint silently, so a companion presence check, forbid service and where (not @@security.zone) (or the equivalent for the other key), belongs alongside a zone-shaped forbid in real governance so nothing sits un-classified in the first place.

require <subject>: <obligation> is the other direction: instead of flagging what shouldn’t exist, it flags what’s missing. It’s evaluated per subject, this bound to each element the subject selector matches; a finding fires when the obligation selector is empty.

policy AuditCoverage {
"Every service (except the audit sink itself) reports to the audit trail."
require service and not AuditLog: this >> AuditLog
}

For every service other than AuditLog itself, the obligation is “reaches AuditLog somewhere downstream” — this >> AuditLog, the directed-cone selector from Chapter 31. Payments and Orders both call AuditLog.record, directly or transitively, so both satisfy it; a service added later with no audit trail fails it immediately.

An obligation must mention this. require service: this > Audit is meaningful; require service: * > Audit desugars to “some service, somewhere, calls Audit” — true or false for every subject alike, which is a targeted diagnostic (the obligation-doesn’t-constrain-this trap). A plain boolean check on the subject itself is the selector this and where (<expr>):

policy ExternalContract {
"Every external dependency records its vendor."
require external_system: this and where (@ext.vendor)
}

except — waivers are visible, owned, and bounded

Section titled “except — waivers are visible, owned, and bounded”

Real systems have sanctioned exceptions. PciEgressControl as written above is honest but incomplete — the analytics call is a real, reviewed decision, not an oversight. except records that decision instead of hiding it:

policy PciEgressControl {
"PCI-zoned workloads never call a public-network module directly."
forbid @@security.zone:"PCI" > @@security.zone:"Public"
except Payments > Analytics.track "Segment tracking is reviewed and approved for now" {
owner: "PlatformSec"
until: "2026-12-01"
}
}

The reason string is mandatory — an except with no reason is a validation error, not a soft warning. It must begin on the waiver’s own logical line. The optional { } body carries governance metadata by convention (owner:, until:, a ticket link) — plain fields the host can surface on a waiver report.

A waived finding isn’t deleted; it’s a distinct, countable state — waiver debt, reported the same way TODO debt is. An except that waives nothing is a lint (“dead waiver — remove”), including one whose target stopped violating the rule. Waivers self-clean as violations get fixed.

A new or changed waiver can’t self-authorize. Rendering and the editor apply a waiver the instant it’s written — that’s what makes it useful day to day. But at a gate (a proposal, a CI run against a base ref), a waiver only suppresses a finding if it was already there in the base you’re diffing against, or if it was introduced or changed in this diff and reviewed as part of it. Nobody can add except and a violation in the same change and have the waiver quietly cover its own violation — see Change gates below for the mechanics.

Placement follows monotonicity. A waiver lives where the policy is owned — a child scope can’t waive a parent’s policy; see Scope & monotonicity.

escalate — raising the stakes, never lowering them

Section titled “escalate — raising the stakes, never lowering them”

escalate <Policy|DIAGNOSTIC> to <severity> raises a named policy’s or a built-in diagnostic’s severity in the current scope. It is raise-only — an escalate that would lower severity is rejected (or simply inert, for the use policy instance-body form below).

policy PlatformGovernance {
"The platform team hardens the org baseline to build failures."
escalate CROSS_SPACE_COUPLING to error
}

This targets a built-in diagnostic by its published SCREAMING_SNAKE key — CROSS_SPACE_COUPLING is the engine’s own check for a call that crosses a space boundary without going through an exported target, normally a warning. Escalating it to error turns “you probably meant to route this through a gateway” into a build failure. Escalating a diagnostic key that a policy can’t otherwise express as a selector is exactly how arch.policy’s GatewayNoBypass works (below).

escalate also takes a policy nameescalate <Policy> to error promotes a policy that ships at warning or advisory (an advisory ownership nudge in the org baseline becomes a hard gate in the platform scope). It only ever raises: escalating an already-error policy is a no-op, and an escalate that would lower severity is rejected.

Every policy carries severity: error | warning | advisory. The default is error — a rule bites by default; softening it is the explicit act.

SeverityEffect
errorFails gates — archlang check, archlang policy-check, the Studio accept-gate.
warningRenders and reports; doesn’t fail a gate (unless the gate runs --strict).
advisoryReport/dashboard surface only — selectable via violating, shown on dashboards, but never in the LSP/CLI diagnostic stream.

TODO stays a separate axis entirely — draft debt gated by the zero-TODO mechanism (archlang validate --complete), never by a policy. arch.policy deliberately leaves a zero-TODO merge gate out of the policy layer for exactly this reason (more in The stdlib policies below).

violating <Policy> — governance drives visuals

Section titled “violating <Policy> — governance drives visuals”

A policy’s active (non-waived) findings are selectable anywhere a selector is: violating <PolicyName>. It carries the policy’s own sort — an edge-forbid policy’s violating is edge-sort, a node require policy’s is node-sort — and reads naturally in a view’s style:

view GovernanceBoard {
"Every active PCI-egress finding, painted red."
show service or gateway or external_system
style violating PciEgressControl { color: crimson }
}

This is the same violating atom Chapter 28 uses to paint zone crossings on a board — a security review opens one view and sees exactly what’s failing, not a document someone has to keep in sync with the model.

State rules (forbid/require) answer “is the model correct right now.” Change gates answer a different question: “does this change need a human’s sign-off.” A when clause is an ordinary policy-body member, evaluated over a base→head diff — a proposal, a commit range — instead of over one snapshot.

policy ChangeReview {
"New PCI egress and zone membership changes route to security review."
when service added { require review from @@team }
when @@security.zone:"PCI" > * added { require review from 2 of @@team }
}

Two verbs, two kinds of subject:

  • Node subjects take added, removed, or changed. The bare form means the element’s own declaration differs base→head; the portal form changed(<getter>) narrows to a resolved-value change — “did @@security.zone change,” wherever the edit was declared — catching a cascade edit that ripples zone membership from a parent.
  • Edge subjects take added/removed only — a derived edge has no declaration of its own, so changed on one is a targeted diagnostic. when @@security.zone:"PCI" > * added is the flagship “new PCI egress route” gate.

The gate body is require review from [N of] <key> — a getter (from @@team, resolved per subject, base-side: a change can’t edit its own reviewers) or a literal key (from SecurityReview). The optional count sets a quorum: 2 of @@team asks two distinct team values to sign off.

Dry-run a change gate against a real base ref before it ever gates a proposal:

$ archlang check my-workspace --against=HEAD
… validate, policy-check, format --check, evidence sections …
─── change gates (vs HEAD) ───
gate ChangeReview — added Refunds
requires review from Payments
✗ 1 change gate would require review vs HEAD (dry-run — the CLI has no approval state)

Adding a new Refunds service (aspect team: "Payments") against this chapter’s model fires exactly that: the when service added gate resolves @@team on the new subject and reports “requires review from Payments.” A gate you can’t rehearse like this is a gate people quietly disable — archlang check --against=<ref> makes it real before it’s load-bearing.

A policy declares at file top level — space-wide, the default — or inside a module body (in <Module> policy … { } for the placed form). A module-scoped policy’s subjects are the selector intersected with that module’s subtree: node findings by subtree membership, edge findings by their source endpoint.

in Payments policy PciEgressReview {
"Payments-local hardening: catch any Public-zone call before it leaves the module."
forbid @@security.zone:"PCI" > @@security.zone:"Public"
}

Placed inside Payments, this evaluates the same rule but only over Payments’s own subtree — a team owning one module can add governance that’s stricter than the org baseline, scoped to what they own.

Governance is monotonic downward. A child scope inherits every policy above it, and may add new policies or escalate an inherited one — it may never weaken, remove, or waive a parent’s policy. A waiver lives where the policy is owned; a subteam can’t quietly except itself out of a rule the platform team wrote. This is the same posture escalate’s raise-only rule enforces at the single-policy level, applied to the whole scope tree: softening a rule is always somebody’s explicit, visible act, never a side effect of where you happen to be standing in the module tree.

Writing OwnershipCoverage-shaped rules from scratch in every workspace is exactly the kind of repetition a package exists to remove. arch.policy ships the common ones, targeting the base kinds (service, external_system, …) so the vocabulary never forks — you keep writing service, and the imported policy already knows what one is.

PolicyRule
OwnershipCoverageEvery service resolves a @@team.
SystemOwnershipEvery system resolves a @@team.
ExternalContractEvery external_system records @ext.vendor.
DomainCoverageEvery service sits in a @@domain.
ZoneIsolationPCI-zoned elements never call Public-zoned ones directly.
ClassificationFlowpii-classified elements are reached only by pii-cleared consumers (a forbid plus a built-in except for cleared callers).
GatewayNoBypassCross-space calls route through an exported gateway — no selector compares a call’s source space to its target’s, so this rides the engine’s own CROSS_SPACE_COUPLING check and hardens it with escalate.

Import one policy per use policy statement — the same “instantiate a governance rule at the use site” shape as importing a type, but for rules instead of vocabulary:

use policy OwnershipCoverage from arch.policy
use policy ExternalContract from arch.policy
use policy ZoneIsolation from arch.policy {
except Payments > Analytics.track "Segment tracking is reviewed and approved for now"
}
use policy GatewayNoBypass from arch.policy

use policy <Name> from <pkg> instantiates the published policy as if it were declared in your own scope: it evaluates over your model, obeys the same local monotonicity your own policies do, and you own the instance. The optional { } body is the only place your own waivers and escalations for that import live — the home package’s body stays read-only from the outside. Its query references still resolve in the home package (the vocabulary travels), but always evaluate against your data — the design SELECTORS.md calls “vocabulary travels, data stays local.” Adopting a package’s policy is always this explicit statement; a dependency version bump can never silently start failing your gate.

An imported policy is enforced exactly like a declared one: archlang check / archlang policy-check and the editor’s diagnostics evaluate use policy … from imports alongside the policies declared in your workspace, so a package rule fails your build the moment its finding fires. A use policy that can’t resolve its home package or policy is itself a gate failure — a governance import that never bites is a hole, not a silent pass.

Two policies arch.policy ships only as comments, deliberately not implemented as rules: a zero-TODO merge gate (ZeroTodo) and a “no new inbound edges to a sunsetting module” diff check (StatusHygiene). Both are genuinely out of shape for forbid/require — TODO is its own diagnostic axis by design (above), and the sunsetting check is a when … added change gate, not a state forbid, which changes what it means enough that folding it into forbid would be silently reinterpreting it rather than expressing it. Worth reading if you’re tempted to write something similar — the comments in policies.arch explain exactly which grammar seam each one falls into.

Rule. Reach for a package policy before writing your own. OwnershipCoverage and friends target the base kinds directly — importing one governs the vocabulary you already write, with no new types to learn and no fork to maintain.

Three surfaces read the same policy declarations:

  • archlang check — the one-shot CI gate: validate + policy-check + format --check, plus (with --against=<ref>) the change-gate dry run. Exit code is the max across phases; an active error finding fails the build.
  • archlang policy-check — evaluates only the policies, on their own: exit 0 clean, 1 on an active error finding, 2 on an active warning under --strict. Advisory findings never fail a gate.
  • The editor (LSP) — surfaces findings as ordinary diagnostics, smart-quiet: they appear once the model resolves coherently, not stacked on top of unrelated parse errors in a half-drafted file. Each diagnostic carries a relatedInformation jump back to the rule that fired it.

Run the dry check locally before it’s someone else’s build that breaks:

$ archlang policy-check my-workspace
file:///…/model.arch:12:9 ERROR PciEgressControl forbid Payments > Analytics.track
1 error, 0 warnings, 0 advisory

Add the waiver from the except example above and the same run comes back clean — the finding didn’t disappear, it moved from “active” to “waived,” and stayed exactly as visible on the waiver report.

  • A policy is a query that yields findings — nodes or edges violating a rule — surfaced as diagnostics, gates, and the violating atom.
  • forbid <selector> flags matches (node- or edge-sort, following the selector); require <subject>: <obligation> flags subjects whose obligation is empty, always via this.
  • except <selector> "<reason>" waives, visibly and with a mandatory reason; a new or changed waiver can’t authorize itself at a gate.
  • escalate <Policy|DIAGNOSTIC> to <severity> raises — never lowers — a policy’s or a built-in diagnostic’s severity.
  • severity: error | warning | advisory is the one axis: error fails gates, warning reports, advisory is dashboard-only.
  • Change gates (when <selector> added|removed|changed(<getter>) { require review from [N of] <key> }) evaluate a base→head diff and are dry-runnable with archlang check --against=<ref>.
  • Scope is file-level or module-placed (in <Module> policy); governance is monotonic downward — a child scope adds and escalates, never weakens or waives a parent’s rule.
  • arch.policy ships the common coverage/isolation/gateway rules, imported one per use policy … from … statement.
  • archlang check / archlang policy-check enforce in CI; the editor surfaces the same findings live.

Chapter 33: Process Representations → — the flow, sequence, and BPMN views a process renders as, and how to pick the right one for the audience.