Skip to content

28. Compliance Boundaries

Most architectural concerns live entirely inside the team — what services exist, what they call, how they’re owned. Compliance is different. Compliance is about boundaries — what data crosses which lines, which services are in PCI scope, which talk to PII, which can reach the public internet. These boundaries exist whether or not you model them. The question is whether the model knows about them.

This chapter shows how to use aspects, views, and validation rules together to make compliance boundaries first-class in the architecture. The result: a model that surfaces violations at parse time, generates compliance views automatically, and makes audit conversations trivial.

A small system whose compliance story lives on three distinct planes — and the single most important modeling decision in this chapter is keeping them apart:

  • Network placement — which segment a module physically sits in (dmz, internal, vpc) and which segments may reach which.
  • Data sensitivity — what class of data a module touches (public, internal, pii, pci, secret).
  • Regulatory scope — which regimes apply (pci, gdpr, …).

A common first pass collapses all of this into one security.zone aspect with values like DMZ, Internal, and PCI mixed together (as the earlier worked chapters do, for brevity). That conflation is exactly the smell a compliance review should catch: DMZ is a network placement, PCI is a regulatory scope, and treating them as one axis means you can’t ask “what’s in the VPC?” separately from “what’s in PCI scope?” Each concern is its own plane; each gets its own aspect key.

We want four things from the model:

  1. Automatic compliance views — “show me everything in PCI scope,” “show me everything touching PII,” “show me what’s in the regulated VPC.”
  2. A materialized network plane — the segments as real modules, with a process that declares which segment may reach which. The network is part of the model, on its own plane, linked to the business plane by aspects.
  3. Boundary enforcement — no dmz module reaches internal except through the gateway; nothing outside the VPC reaches a pci module directly. Enforced by policy, not by reviewer vigilance.
  4. Onboarding clarity — a new engineer reading the model knows, for each service, its segment, its data class, and its regimes.

Three aspect axes, one per plane, all cascading:

AspectValuesPlane
network-zonedmz, internal, vpcNetwork segmentation (materializable — see below)
data.classificationpublic, internal, pii, pci, secretData sensitivity
compliance.regimepci, gdpr, ccpa, sox, hipaaRegulatory scope

These axes are conventions, not language built-ins. You declare them in your project’s types (or lean on the stdlib defaults), then every module sets them as appropriate. The key is one plane per key: network-zone answers “where does it run,” data.classification answers “how sensitive is its data,” compliance.regime answers “what rules govern it.” Mixing them into one key is the anti-pattern.

Multi-membership uses a list value. A module that’s in scope for both GDPR and PCI carries both on one aspect:

aspect {
compliance.regime: ["gdpr", "pci"] // member of both regime aspects
}

A list-valued aspect makes the module a member of every aspect in the list (per the spec’s aspect rules, §4.4) — there’s no need for one boolean key per regime.

platform.arch (selectively, just to show the aspects):

frontend StoreFront {
aspect team: "Frontend"
aspect {
domain: "Commerce"
network-zone: "dmz"
data.classification: "public"
}
"Customer-facing storefront. Public-internet exposed."
}
gateway WebGateway {
aspect team: "Platform"
aspect {
domain: "Platform"
network-zone: "dmz"
data.classification: "internal"
}
"Edge gateway. Terminates TLS, applies WAF rules, forwards into the internal segment. The single sanctioned crossing from dmz to internal."
rest_create forward { "Reverse-proxy inbound API traffic to internal services." }
}
service Orders {
aspect team: "Commerce"
aspect {
domain: "Commerce"
network-zone: "internal"
data.classification: "pii"
compliance.regime: ["gdpr"]
}
"Order processing. Stores customer name/address/email — PII."
rest_create createOrder { "Place an order. Reached only via the gateway." }
}
service Payments {
aspect team: "Payments"
aspect {
domain: "Payments"
network-zone: "vpc"
data.classification: "pci"
compliance.regime: ["pci"]
}
"Card payment processing. Card data NEVER leaves this module unencrypted."
}
database PaymentVault {
aspect team: "Payments"
data.encryption.algorithm: "aes-256-gcm" // a property → field, not an aspect
aspect {
domain: "Payments"
network-zone: "vpc"
data.classification: "pci"
compliance.regime: ["pci"]
}
"Encrypted vault for tokenized card data."
}

Two things to notice. WebGateway is a gateway, not a generic service — it’s a high-level peer, the one place dmz-to-internal traffic is allowed to cross, and that’s where edge policy (auth, WAF, rate limits) lives. data.encryption.algorithm is a field, not an aspect — the algorithm is a structured property of the vault, not a cross-plane connection. Aspects are for membership in a plane; specific values like an encryption algorithm or a runbook URL belong in fields.

Mindset shift. Compliance aspects are not optional metadata; they define behavioral constraints. data.classification: pci isn’t a tag — it’s a contract that this module’s data and calls are treated to PCI rules. If you only classify the modules that happen to be compliant, you get false negatives (an internal module slipping a call into pci past review). Set the aspects everywhere, including on public services. The model is comprehensive or it’s useless.

So far network-zone: "dmz" is a pure aspect — a string-valued classification that groups every dmz module together on an invisible plane. That’s already useful (you can focus a view on it), but a network is a real thing with real rules. You can materialize the plane: turn each segment into an actual module, write a process that declares the allowed flows, and link the business modules to it.

network.arch:

// A type that earns its keep: every segment must declare its CIDR and link
// to its firewall console, and ships the ingress interface that flows target.
export type module network_segment {
required cidr
required ext.firewall.console.url
rest_create ingress { "Traffic admitted into this segment." }
"A network segment. Cross-segment reachability is declared by the
NetworkPolicy process — a flow with no step is denied by default."
}
network_segment Dmz {
aspect team: "Platform"
cidr: "10.0.0.0/24"
ext.firewall.console.url: "https://fw.acme.com/dmz"
"Public-facing segment. Terminates inbound internet traffic."
}
network_segment Internal {
aspect team: "Platform"
cidr: "10.0.1.0/24"
ext.firewall.console.url: "https://fw.acme.com/internal"
"Private segment. No direct internet ingress."
}
network_segment Vpc {
aspect team: "Platform"
cidr: "10.0.2.0/24"
ext.firewall.console.url: "https://fw.acme.com/vpc"
"Isolated VPC for regulated (PCI) workloads."
}
// The policy spans all three segments, so it lives at the root — the
// smallest scope that contains every segment it touches.
process NetworkPolicy {
Internet > Dmz.ingress "443 only, WAF-filtered"
Dmz > Internal.ingress "north-south, mTLS"
Internal > Vpc.ingress "PCI workloads only, mTLS"
// No `Dmz > Vpc.ingress` step — the DMZ may NOT reach the VPC
// directly. The absence of a step IS the policy: only declared
// flows are permitted.
}

network_segment is a real type, not a do-nothing wrapper: it forces a CIDR and a firewall-console link on every segment, and ships the ingress interface the policy process targets. NetworkPolicy declares the segment topology — three hops, no shortcut from dmz to vpc. It sits at the root because it spans all three segments; a process lives at the smallest scope that contains everything it touches.

Now connect the business plane to the network plane. The network-zone aspect string becomes a bare reference to the real segment module:

service Payments {
aspect {
domain: "Payments"
network-zone: Vpc // bare value → reference to the Vpc segment module
data.classification: "pci"
compliance.regime: ["pci"]
}
// ...
}

network-zone: "vpc" (quoted) is a weak aspect — a classification, “lives in the vpc zone.” network-zone: Vpc (bare) materializes that into a hard link (membership) to the modeled segment. The aspect is the connection between planes: the business module stays on the service plane, the segment lives on the network plane, and the aspect is the thread joining them. This is the general pattern for any deployment plane — the same way a database links to its DBMS and a DBMS links to its host.

Rule. Materialize a plane when it has rules of its own. A network you only tag is documentation; a network you model — segments, ingress interfaces, an allowed-flows process — is something the model can check. Promote the aspect to real modules the moment “which zone may reach which” becomes a question you need answered.

A process where the gateway is the boundary

Section titled “A process where the gateway is the boundary”

The network policy governs segment-to-segment reachability. Business processes ride on top of it — and they must show the crossings honestly:

process #h8r4ja PlaceOrder {
Customer > WebGateway.forward "TLS-terminated at the edge, WAF-filtered"
WebGateway > Orders.createOrder "dmz → internal, the one sanctioned crossing"
Orders > Payments.authorize "internal → vpc"
}

WebGateway appears in the call path because it is in the call path. The temptation is to write Customer > Orders.createOrder and treat the gateway as plumbing — but that’s exactly the omission to avoid.

Rule. Never omit a gateway from a process. A gateway is a high-level peer, not an encapsulated detail (unlike a service’s own nested database, which you may skip). Dropping it hides the dmz→internal crossing and the policy enforced there — Customer > Orders.createOrder would imply the public internet reaches an internal service directly, a boundary violation the model should surface, not conceal. Omitting the gateway is how policy violations go invisible.

views.arch:

view PCIScope {
"Every module in PCI scope. Used in quarterly compliance review."
show @@compliance.regime:"pci"
group by @@team
}
view PIIScope {
"Every module touching PII. Used in data-mapping exercises."
show @@data.classification:"pii"
group by @@team
}
view PublicSurface {
"Internet-exposed segment. Useful for pen-test scoping."
show @@network-zone:"dmz"
}
view RegulatedVpc {
"Everything in the regulated VPC, plus the segment itself."
show @@network-zone:"vpc"
group by @@team
}
view CrossZoneFlow {
"All processes; highlights edges that cross network zones (look for any dmz→vpc shortcut)."
show process
style @@network-zone:"dmz" > @@network-zone:"vpc" { color: crimson }
}

The focused views are trivial once aspects are set — each axis is a different plane, so each view slices a different concern. CrossZoneFlow is the same model with the forbidden dmz→vpc edges flagged red by a style clause; because the network plane is materialized, an unexpected dmz→vpc edge shows up as a real crossing, not a hidden one.

This is where the metamodel earns its keep. The stdlib’s validator runs a small set of built-in checks (required blanks, callable resolution). Custom checks live in your project’s types — required blanks specifically targeted at compliance facts.

types.arch:

// Every service in PCI scope must declare a runbook and a contact.
export type service pci_service {
required aspect team
required ext.runbook_url
required ext.compliance.contact
required aspect data.classification // instance must pick a value (pii, pci, secret, ...)
aspect {
compliance.regime: ["pci"] // a compliance fact, by construction
}
"A service in PCI scope. Compliance facts are enforced here; network
placement (network-zone) is a separate plane, set per instance."
}
// Every service touching PII must declare a retention policy.
export type service pii_service {
required aspect team
required data.retention_days
required data.deletion_endpoint
aspect {
data.classification: "pii"
}
}

Note that pci_service encodes only the compliance plane — regime, runbook, contact, data class. It deliberately does not pin a network-zone: where the service runs is a different plane, set independently on the instance. Folding placement into the compliance type would re-create the conflation this chapter exists to avoid.

Now instead of service Payments, write:

pci_service Payments {
aspect team: "Payments"
ext.runbook_url: "https://wiki.acme.com/pci-runbook"
ext.compliance.contact: "compliance@acme.com"
aspect {
data.classification: "pci" // fulfills the required blank
network-zone: Vpc // network placement, set independently
}
rest_create authorize
rest_create capture
rest_create refund
}

If a developer adds a pci_service without one of the required fields, validation fails. Compliance facts are no longer “we should remember to add this” — they’re enforced at parse time.

Enforce boundaries with policy, not convention

Section titled “Enforce boundaries with policy, not convention”

A boundary that matters should be enforced by the model, not by a reviewer remembering to look. Built-in validation catches structural errors (caller is a module, callee is an interface) and required-blank gaps. Cross-cutting boundary rules — “no dmz module reaches internal except through the gateway,” “nothing outside the VPC reaches a pci module directly” — are the job of policies.

Policies (query-based checks, spec §10) are declared next to the model and enforced by archlang check — a crossing fails the build with the policy named. A forbid rule flags every edge its selector matches; an except waiver carves out the sanctioned crossings:

policy NoDmzToInternalExceptGateway {
"DMZ traffic must transit the WebGateway to reach the internal segment."
severity: error
forbid @@network-zone:"dmz" > @@network-zone:"internal"
except WebGateway > @@network-zone:"internal" "WebGateway is the one sanctioned dmz→internal crossing"
}
policy NoOutsideToPci {
"Only VPC-resident callers may reach a PCI-scoped service."
forbid (not @@network-zone:"vpc") > @@compliance.regime:"pci"
}

The separation is declared, archlang check enforces it, and a crossing fails the build with the policy named. Don’t settle for convention. For a procedural check the declarative policy language doesn’t express, you can still script over the resolved model with @archlang/engine (Chapter 24):

// scripts/check-zones.ts — a procedural check over the resolved model.
import { loadPackage, ioNode } from "@archlang/lsp";
import { buildGraph } from "@archlang/engine";
// loadPackage resolves + cascades the package (see Chapter 24 for the API).
const loaded = await loadPackage(ioNode(), new URL(".", import.meta.url).href);
const graph = buildGraph(loaded.resolved.model);
function zone(moduleId: string | undefined): string | undefined {
if (!moduleId) return undefined;
return graph.modulesById.get(moduleId)?.aspects.get("network-zone");
}
function isGateway(moduleId: string | undefined): boolean {
if (!moduleId) return false;
return graph.modulesById.get(moduleId)?.type === "gateway";
}
const violations: string[] = [];
for (const edge of graph.edges) {
const fromZone = zone(edge.fromModuleId);
const toZone = zone(edge.toModuleId);
if (fromZone === "dmz" && toZone === "internal" && !isGateway(edge.fromModuleId)) {
violations.push(`${edge.fromName}${edge.toName} (dmz→internal, not via gateway)`);
}
if (toZone === "vpc" && fromZone !== "vpc") {
violations.push(`${edge.fromName}${edge.toName} (outside→vpc)`);
}
}
if (violations.length > 0) {
console.error("Zone violations:\n" + violations.join("\n"));
process.exit(1);
}

Run it in CI; violations fail the build. Prefer a policy block whenever the rule is expressible declaratively — it travels with the model and archlang check runs it. Reach for a script only for a check the policy language can’t express.

Rule. Back boundaries that matter with policies, not convention alone. A reviewer-enforced boundary is one tired afternoon away from being breached; a policy-enforced boundary fails the build.

After the aspects and types are in place:

  • The PCI quarterly review — open the PCIScope view, screenshot, file. The view is exact and current because it’s a function of the source.
  • The PII data map — open PIIScope. Every service touching PII is listed; for each, retention and deletion are visible.
  • The DPIA inventory — script over the resolved model: list every module whose compliance.regime list contains gdpr.
  • A network audit — open the materialized network plane and the NetworkPolicy process: every allowed segment-to-segment flow is one declared step, and a regulator can read the topology directly.
  • A boundary violation — a developer adds a service with no compliance aspects and a process step into a PCI service. The policy fails the build. The developer either justifies the call (in which case it’s reviewed) or removes it.

None of this requires keeping a parallel compliance document up to date. The document is the model.

Aspect coverage. Either every module carries network-zone and data.classification, or you have invisible gaps. Reach completeness by making the aspects required on your types. Cost: a one-time pass updating existing instances. Benefit: ironclad coverage.

Cascade vs explicit. A system PaymentsDomain { ... } container with aspect { network-zone: Vpc } cascades to every contained service. Cleaner than repeating the aspect five times. Use cascade for shared placement; set an explicit aspect only where a single module is the exception.

Classification or materialized. A network-zone: "vpc" string is enough when you only need grouping and views. Promote it to a materialized plane (real segment modules + a NetworkPolicy process + bare-reference aspects) when reachability rules become something you need to enforce or audit. The two coexist: start as a classification aspect, materialize into membership when the questions get harder.

Where the rules live. Project-local types (pci_service, pii_service, network_segment) encode the vocabulary. In a large org, hoist the shared ones into a dedicated, isolated types package and use them everywhere — the escape hatch for cross-team shared vocabulary (Chapter 29).

What to NOT model. Data-retention implementation (the cron job that runs the deletion). Audit-log destinations. Anything that’s an implementation choice should be captured in fields (“which one”) not in the model’s structure (“does it exist”) — exactly why the encryption algorithm is a field on the vault, not an aspect.

  • One plane per aspect key: network-zone (placement), data.classification (sensitivity), compliance.regime (regulatory scope). Conflating them into one security.zone axis is the smell.
  • Multi-membership is a list value (compliance.regime: ["gdpr", "pci"]), not a key-per-value.
  • Cascade those aspects — set them once, reach everything inside.
  • Materialize the network plane when reachability has rules: real network_segment modules, a NetworkPolicy process whose missing steps are the denied flows, and bare-reference aspects joining the business plane to it.
  • Never omit a gateway from a process — it’s the high-level peer where the boundary and its policy live.
  • Properties (encryption algorithm, runbook URL) are fields; plane membership is aspects.
  • Types (pci_service, pii_service, network_segment) make required facts enforceable at parse time.
  • Enforce boundaries with policies run by archlang check; drop to a CI script on @archlang/engine only for procedural checks, never convention alone.
  • The model becomes the compliance document.

Chapter 29: Designing a Metamodel → — type-author tutorial. Build a domain-specific vocabulary on top of the stdlib.