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 labels, 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.

The scenario

A small system spanning three security tiers: public-facing services (DMZ), internal services (Internal), and PCI-scoped services (PCI). Plus a separate data-classification axis: services touching PII have to be marked.

We want three things from the model:

  1. Automatic compliance views — “show me everything in PCI scope,” “show me everything touching PII.”
  2. Boundary enforcement — no service in Internal should be called by a service in DMZ directly; PCI services should never be called from outside PCI.
  3. Onboarding clarity — a new engineer reading the model should know, for each service, what compliance constraints apply.

The labels

Two label axes, both cascading:

LabelValuesPurpose
security.zonePublic, DMZ, Internal, PCI, ExternalNetwork/security tier
data.classificationpublic, internal, pii, pci, secretData sensitivity

Both axes are conventions. They aren’t built into the language. You declare them in your project’s stdlib (or use the stdlib defaults), then every module sets them as appropriate.

Optionally, a third axis:

LabelValuesPurpose
compliance.regimegdpr, ccpa, pci-dss, sox, hipaaWhich regulatory framework applies

Multi-value labels aren’t supported — a module that’s both GDPR and PCI scope gets a label per regime by declaring two: compliance.regime.gdpr: true, compliance.regime.pci: true. The dotted-key field form covers this cleanly.

The modules

platform.arch (selectively, just to show labeling):

frontend StoreFront {
team: Frontend
labels {
domain: Commerce
security.zone: Public
data.classification: public
}
"Customer-facing storefront. Public-internet exposed."
}
service WebGateway {
team: Platform
labels {
domain: Platform
security.zone: DMZ
data.classification: internal
}
"Edge gateway. Terminates TLS, applies WAF rules, forwards into Internal."
}
service Orders {
team: Commerce
labels {
domain: Commerce
security.zone: Internal
data.classification: pii
}
"Order processing. Stores customer name/address/email — PII."
}
service Payments {
team: Payments
labels {
domain: Payments
security.zone: PCI
data.classification: pci
compliance.regime.pci: true
}
"Card payment processing. Card data NEVER leaves this module unencrypted."
}
database PaymentVault {
team: Payments
labels {
domain: Payments
security.zone: PCI
data.classification: pci
data.encryption: aes-256
}
"Encrypted vault for tokenized card data."
}

Note compliance.regime.pci: true — a boolean field works well for “this module is in scope for that regime.” Multiple regimes get multiple keys.

Mindset shift. Compliance labels are not optional metadata. They define behavioral constraints. A label of security.zone: PCI isn’t a tag — it’s a contract that this module’s data and calls will be treated to PCI rules. If you only carry labels for the modules that happen to be compliant, you’ll get false negatives (a Internal module slipping a PCI call past validation). Set the labels everywhere, including for Public services. The model is comprehensive or it’s useless.

The views

views.arch:

view PCIScope {
focus security.zone: PCI
group by team
layout dagre
"Every module in PCI scope. Used in quarterly compliance review."
}
view PIIScope {
focus data.classification: pii
group by team
layout dagre
"Every module touching PII. Used in data-mapping exercises."
}
view PublicSurface {
focus security.zone: Public
focus security.zone: DMZ
layout dagre
"Internet-exposed surface. Useful for pen-test scoping."
}
view CrossZoneFlow {
layout dagre
"All processes; highlights edges that cross security zones."
}

The first three are trivial once labels are set. CrossZoneFlow is the same model but with a layout that gives reviewers the easiest read of zone boundaries (typically dagre or hierarchical works best for that).

Validation rules

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.

kinds.arch:

// Every service in PCI scope must declare a runbook and a contact.
export type service pci_service {
required cascade team
required ext.runbook_url
required ext.compliance.contact
required labels.data.classification // instance must pick a value (pii, pci, secret, ...)
labels {
security.zone: PCI
compliance.regime.pci: true
}
}
// Every service touching PII must declare a retention policy.
export type service pii_service {
required cascade team
required data.retention_days
required data.deletion_endpoint
labels {
data.classification: pii
}
}

Now instead of service Payments, write:

pci_service Payments {
team: Payments
ext.runbook_url: "https://wiki.acme.com/pci-runbook"
ext.compliance.contact: "compliance@acme.com"
labels {
data.classification: pci // fulfills required blank
}
command Authorize
command Capture
command 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.

Process-level constraints

Built-in process validation catches structural errors (caller is a module, callee is an interface). Compliance-level constraints — “no DMZ service should call an Internal service directly” — are not yet first-class in the validator. The pattern that works today:

Use labels and views to surface violations visually, and a small custom check script (built on @archlang/core, Chapter 24) to enforce them in CI:

scripts/check-zones.ts
import { loadPackage } from "@archlang/lsp";
import { NodeIO } from "@archlang/lsp/node";
import { resolvePackage, buildGraph } from "@archlang/core";
const io = new NodeIO();
const loaded = await loadPackage(io, ".");
const model = resolvePackage(loaded.input, loaded.deps).model;
const graph = buildGraph(model);
function zone(moduleId: string | undefined): string | undefined {
if (!moduleId) return undefined;
const m = graph.modulesById.get(moduleId);
return m?.labels.get("security.zone");
}
const violations: string[] = [];
for (const edge of graph.edges) {
const fromZone = zone(edge.fromModuleId);
const toZone = zone(edge.toModuleId);
if (fromZone === "DMZ" && toZone === "Internal") {
violations.push(`${edge.fromName}${edge.toName} (DMZ→Internal)`);
}
if (fromZone !== "PCI" && toZone === "PCI") {
violations.push(`${edge.fromName}${edge.toName} (outside→PCI)`);
}
}
if (violations.length > 0) {
console.error("Zone violations:\n" + violations.join("\n"));
process.exit(1);
}

Run it in CI. Violations fail the build. Compliance is now machine-enforced.

What governance teams see

After the labels 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 with compliance.regime.gdpr: true.
  • A boundary violation — a developer adds a service with no compliance labels and a process step into a PCI service. CI fails. 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.

Decisions you’ll face

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

Cascade vs explicit. A system PaymentsDomain { ... } container with labels { security.zone: PCI } cascades to every contained service. Cleaner than repeating the label five times. Use cascade for shared zones; use explicit labels when a single module is the exception.

Where the rules live. Project-local types in your stdlib (pci_service, pii_service) encode the compliance vocabulary. Reuse them across packages by exporting from a shared package and importing.

What to NOT model. Data retention implementation (the cron job that runs the deletion). Specific encryption algorithms (those are operational, sometimes per-instance). 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”).

Summary

  • Labels (security.zone, data.classification, compliance.regime.*) are how the model carries compliance facts.
  • Cascade those labels — set them once, reach everything inside.
  • Type kinds (pci_service, pii_service) make required compliance fields enforceable at parse time.
  • Compliance views (focus security.zone: PCI) fall out of labels — no manual maintenance.
  • Process-level boundary checks live in a small CI script built on @archlang/core.
  • The model becomes the compliance document.

What’s next

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