archspec turns one YAML file, the spec, into architecture and sequence diagrams, a security review of every connection, data mappings and implementation briefs, and checks it for mistakes the way a compiler checks code. Everything runs in your browser; nothing you type is uploaded.
Every example on this page was drawn by the archspec engine from the spec next to it. Paste one into the playground to change it.
The app
The spec editor is on the left and the views it produces are on the right. Everything updates as you type. Click something in a view to see where it is written in the spec, and click a line of the spec to see it in the view (see Moving between levels). Need more room? « Hide in the Spec header hides the code on every tab until you click Show spec ».
| Tab | What it shows |
|---|---|
| Findings | Problems in the spec, worst first. Click one to jump to its line; mark it reviewed, or done with a comment. |
| Architecture | Components grouped by trust zone, with every connection. Edit it directly on the diagram. |
| Sequence | The flow, step by step. Edit it directly on the diagram. |
| API | A Swagger-style reference for any box: endpoints, payloads, responses, errors, message handlers, calls. Download OpenAPI. |
| Connections | The ports and protocols matrix: every connection with direction, port, encryption, auth and data. |
| Boundaries | Only the connections that cross a trust boundary. |
| Mappings | Where every column and payload field gets its value. |
| Agent brief | The slice of the spec a developer or coding agent needs to build one step. |
| DDL | Postgres tables generated from the spec. |
| Export | Downloads: SVG, PNG, CSV, Markdown (security tables, API reference), Mermaid, LikeC4, the spec. |
| Platform | The rule pack and the catalogs your spec imports. |
| Help | This guide. |
Pick an Example at the top to see a complete spec, or click New to start from scratch: you get a blank spec on the Architecture tab with the form for your first component open. If the current spec has unsaved changes, New asks first, and Undo brings it back.
The examples
Each example shows a different architecture style. Open one from Example and look at its Architecture, Sequence and Boundaries tabs.
| Example | Shows |
|---|---|
| Online shop | Business view first: customer → website → order processing, then drill into each system and component |
| Order placed | Transactional outbox, SQS queue, idempotent consumer |
| Payment captured | Synchronous chain to an external card processor, PCI data, double-entry ledger |
| User sign-up | Public endpoint with a documented exception, credentials label, email vendor |
| Product catalog | API gateway, gRPC, cache-aside reads with select, cache invalidation on writes |
| Clickstream analytics | Event streaming through Kafka with a consumer group and dead-letter topic |
| Lab results upload | Object storage for health data (PHI), outbox, SMS vendor that only gets a reference |
| Partner webhook | Signed inbound webhooks, inbox pattern so partner retries are harmless |
| Order placed, with planted mistakes | Every rule firing at least once: a tour of the findings |
Start with the business flow
You don't need protocols and tables to start. Describe the business first: who uses which systems, and what passes between them. Then open each system and design what is inside it.
| Level | Shows | You write |
|---|---|---|
| 1. Business view | People and systems, joined by plain-language links | kind: person, kind: system, links (connections with a label and no type) |
| 2. Inside a system | Its components: services, workers, databases, queues | components with partOf: <system>, typed connections |
| 3. Inside a component | Its endpoints, message handlers and scheduled jobs (from the flow), and its parts | flow steps, components with partOf: <component> |
components:
customer: { kind: person, title: Customer }
website: { kind: system, title: eCommerce Website }
orders: { kind: system, title: Order Processing Engine }
warehouse: { kind: external, title: Warehouse Partner }
connections:
customer_to_website: { from: customer, to: website, label: places orders }
website_to_orders: { from: website, to: orders, label: submits orders }
orders_to_warehouse: { from: orders, to: warehouse, label: sends picking lists }Links are connections without a type. They say what the relationship is, in business
terms, and the technical rules skip them. Once you add typed connections between the
components inside, the link shows how underneath its label, such as via gRPC. A link with nothing designed
under it yet is drawn dash-dotted, labelled not designed yet, and reported as DES001 (info),
so you can see what is still to do.
Drilling down. On the Architecture tab, a box with more inside it is drawn as a stack and has an open › button. Click it to look inside: you see the components of that system, with whatever they talk to outside it at the sides. Lines between boxes roll up: a line between two systems stands for every connection between their components. Inside a component you see its endpoints (one per call it receives), message handlers and scheduled jobs, taken from the flow. The bar above the diagram shows where you are; click a level in it, or ↑ Up a level, to go back.
The Sequence tab follows the same level. At the top it shows what happens between people and systems, and steps that stay inside one system collapse into one line, such as S10–S12: 3 steps inside Order Processing Engine. Click that line to open the system.
The sequence follows the architecture. Connections and links that no flow step uses yet appear on the Sequence tab straight away as dashed draft messages, in call order from the callers. Click a draft connection to add it to the flow as a step (the kind follows from the ends: a call, a transaction, a publish or a consume), or Add n drafts to the flow to add them all. Draft links stay drafts until you design them with connections inside the systems.
Specs without systems keep working as before: the Architecture tab shows every component.
Spec file basics
A spec is a YAML file, usually named something.spec.yaml. These keys come first:
archspec: 1 # format version
spec: order-placed # lowercase name, used for file names and titles
summary: Client places an order; fulfillment creates a shipment.
imports: # platform catalogs (see "Platform catalogs")
- ../platform/connection-types.yaml
- ../platform/vendors.yaml
- ../platform/data-labels.yaml
Then come the sections, all optional except components and flow:
| Section | Holds |
|---|---|
components |
Services, workers, databases, queues and external parties |
connections |
Every edge between components, with protocol, auth and the data it carries |
types |
Payload contracts: requests, responses, events |
tables |
Database tables and columns |
flow |
The ordered steps of the sequence diagram |
invariants |
Plain-language truths the implementation must keep |
scenarios |
Acceptance tests that cover the flow |
reviews |
Triage of findings: reviewed, or done with a comment |
In the examples on this page the header lines are left out for brevity; the app adds them.
Components
Each component has an id (lowercase, - and _ allowed) and a kind. The kind sets the
shape in the diagrams.
| Kind | Shape | Use for |
|---|---|---|
person |
rounded box with a head | People and roles: customer, merchant, support agent |
system |
box with a thick border | A whole application or product, made of components (business view) |
external |
rounded pill | Browsers, mobile apps, partners, third-party APIs |
service |
rectangle | Things that serve requests |
worker |
dashed rectangle | Background processes: relays, schedulers, consumers |
database |
cylinder | Anything with tables |
queue |
rectangle with end bars | SQS queues, topics, streams |
components:
web: { kind: external, title: Web App }
api: { kind: service, title: Orders API, owns: [orders_db] }
orders_db: { kind: database, title: Orders DB, engine: postgres }
connections:
web_to_api: { from: web, to: api, type: https-rest, auth: oauth2-jwt, edgeProtection: WAF, timeout: 3s, retryPolicy: none }
api_to_orders_db: { from: api, to: orders_db, type: postgres, auth: iam-db-auth, statementTimeout: 1s, connectionPool: 10 }Other component keys:
| Key | Meaning |
|---|---|
title |
Display name in diagrams |
zone |
Trust zone, such as internet, dmz, private |
owns |
Databases this service owns. Only the owner may connect to them (rule P7). |
partOf |
The system or component this one sits inside: a service inside a system, an outbox relay inside a service. It appears when you open that box, shares the owner's databases, and is nested in the LikeC4 export. |
engine |
Database engine, shown under the title |
vendor |
For third parties we call: the approved-vendor id (rule NET002) |
description |
Free text |
Zones and trust boundaries
Give components a zone. A connection between two zones crosses a trust boundary:
archspec works the boundary out for you (dmz->private), draws zones as shaded bands, and
holds boundary crossings to stricter rules.
- Connections that cross a boundary must authenticate (rule P2).
- Connections from an external party are public endpoints and must say how they are
protected at the edge with
edgeProtection(rule NET003). - Connections to an external party are outbound vendor calls; the target needs an
approved
vendor(rule NET002).
components:
web: { kind: external, title: Web App, zone: internet }
api: { kind: service, title: Profile API, owns: [db], zone: dmz }
db: { kind: database, title: Profile DB, zone: private }
mail: { kind: external, title: Email Provider, vendor: relay-mail, zone: internet }
connections:
web_to_api: { from: web, to: api, type: https-rest, auth: oauth2-jwt, edgeProtection: "WAF, 20 req/s per user", timeout: 3s, retryPolicy: none }
api_to_db: { from: api, to: db, type: postgres, auth: iam-db-auth, statementTimeout: 1s, connectionPool: 10 }
api_to_mail: { from: api, to: mail, type: https-rest, auth: api-key, timeout: 5s, retryPolicy: provider dedupes }Callers are drawn on the left and third parties on the right, so traffic reads left to right.
You can also write boundary: internet->dmz yourself; it must match the zones (rule NET004).
Connections
Every edge is a connection with an id and a type from the platform catalog. Name it after
its ends, <from>_to_<to> (orders-api_to_orders_db), so flow steps read on their own:
via: orders-api_to_orders_db. The editor names new connections this way, and renaming a
component renames the connection ids made from it. A second connection between the same two
components gets _2. Short ids such as C1 work too; diagrams print short ids on the line and
leave descriptive ones to the tooltip, since the line already joins the two named boxes.
The type sets the protocol, default port, sync or async, encryption and the allowed auth
methods, and lists the properties every connection of that type must set.
| Type | Protocol / port | Mode | Must set |
|---|---|---|---|
https-rest |
HTTPS 443 | sync | timeout, retryPolicy |
internal-grpc |
gRPC 443, mTLS | sync | timeout, deadlinePropagation |
sqs-standard |
AWS SQS 443 | async, at-least-once | consumers: dlq, visibilityTimeout |
postgres |
PostgreSQL 5432 | sync | statementTimeout, connectionPool |
https-webhook |
HTTPS 443, signed | sync | timeout, retryPolicy (for third parties calling us) |
kafka |
Kafka 9098 | async, at-least-once | consumers: consumerGroup, dlq |
redis |
Redis 6379 | sync | timeout |
s3 |
S3 API over HTTPS 443 | sync | timeout; set encryption.atRest |
connections:
payments-svc_to_card-processor:
from: payments-svc
to: card-processor
type: https-rest
auth: api-key # must be one the type allows (rule P11)
port: 8443 # optional: overrides the type's default port
timeout: 4s # durations: 250ms, 4s, 2m
retryPolicy: idempotency key makes a retry safe
carries: [ChargeRequest, ChargeResult] # data types sent over it
encryption: { atRest: SSE-KMS } # queues and databases: encryption at rest
Queue consumers also take dlq (the dead-letter queue component) and idempotentConsumer (the
table used to skip duplicate deliveries, rule P4).
Data types
Types describe payloads: requests, responses and events. Type names are PascalCase.
components:
web: { kind: external, title: Web App, zone: internet }
api: { kind: service, title: Profile API, zone: dmz }
connections:
web_to_api: { from: web, to: api, type: https-rest, auth: oauth2-jwt, edgeProtection: WAF, timeout: 3s, retryPolicy: none, carries: [ProfileUpdate, ProfileSaved] }
types:
ProfileUpdate:
description: Name and email change
classification: confidential
fields:
userId: { type: uuid }
email: { type: string, pii: true }
name: { type: string, labels: [pii], optional: true }
address: { type: Address }
Address:
fields:
lines: { type: "string[]" }
country: { type: string, enum: [GB, US, DE] }
ProfileSaved:
description: Confirmation
fields:
status: { type: string, const: saved }| Conn | From → To | Direction | Mode | Protocol | Port | In transit | At rest | Auth | Boundary | Timeout | Data |
|---|---|---|---|---|---|---|---|---|---|---|---|
web_to_api | web → api | inbound | sync | HTTPS | 443 | TLS1.2+ | — | oauth2-jwt | internet->dmz | 3s | ProfileUpdate: Name and email change; ProfileSaved: Confirmation [PII] |
Field types: uuid, string, int, number, bool, timestamp, json, another type's
name, or a list such as "OrderItem[]" (quote it in YAML).
| Field key | Meaning |
|---|---|
enum |
Allowed values. Mapped literals are checked against it. |
const |
The one allowed value, such as an event type or schema version |
optional |
Builders don't have to set it |
deprecated |
true, or what to use instead. Setting it in a mapping is a warning (CON004). |
pii: true / labels: [...] |
Sensitivity, see the next section |
description |
Free text |
A type's description appears in the Data column of the security tables.
Sensitive data labels
Label fields and columns with what kind of data they hold. Labels come from the platform
catalog data-labels.yaml:
| Label | Means | Must be encrypted |
|---|---|---|
pii |
Personal data | yes |
pci |
Cardholder data (PCI DSS scope) | yes |
phi |
Health data | yes |
credentials |
Secrets, passwords, tokens | yes |
financial |
Financial records | no |
pii: true is shorthand for labels: [pii]. Labels travel with the data. A connection
carrying a type, or a database connection writing a table, gets every label inside it,
including labels inside nested types and JSON columns.
- Encryption (rule P1): a connection carrying a label marked "must be encrypted" needs encryption in transit, and at rest when it goes into a queue or database.
- Vendors (rule NET002): each approved vendor lists the labels it may receive
(
dataAllowed). Sending PCI data to a vendor approved only for PII is an error. What counts as sent comes from the flow: request and publish bodies go to the vendor,respondbodies come back from it. A connection no step uses is checked against itscarries. - Unknown labels are errors (rule DAT004).
Sensitive connections are drawn in orange, and the Data column shows red flags for labels that must be encrypted and blue ones for the rest.
Tables
Tables are named database.table and belong to the database component before the dot.
tables:
orders_db.orders:
description: Orders
columns:
id: { type: uuid, pk: true }
customer_id: { type: uuid, pii: true }
status: { type: text, enum: [PLACED, CANCELLED] }
total_cents: { type: bigint, labels: [financial] }
idempotency_key: { type: text, unique: true }
notes: { type: text, nullable: true }
created_at: { type: timestamptz, default: now() }
payload: { type: jsonb, schema: OrderPlacedEvent } # JSON with a known shape
Column types: uuid, text, int, bigint, numeric, bool, timestamptz, jsonb.
Columns are NOT NULL unless nullable: true. Every insert must set every NOT NULL column that
has no default (rule DAT001).
Flow (the sequence)
flow is the ordered list of steps that becomes the sequence diagram. Each step has an id
(S1, S2, S2a, …), goes over one connection (via), and is exactly one of six kinds:
| Step | Arrow | What happens |
|---|---|---|
call |
solid → | The connection's from calls its to with a request |
respond |
dotted ← | The callee answers with a body |
transaction |
solid → database | One database transaction: insert, update or delete operations |
poll |
solid → database | A worker reads rows on a schedule (every: 1s) |
publish |
dashed → queue | A message is sent to a queue |
consume |
dashed → worker | A consumer receives a message |
components:
web: { kind: external, title: Web App, zone: internet }
api: { kind: service, title: Orders API, owns: [orders_db], zone: dmz }
orders_db: { kind: database, title: Orders DB, zone: private }
connections:
web_to_api: { from: web, to: api, type: https-rest, auth: oauth2-jwt, edgeProtection: WAF, timeout: 3s, retryPolicy: same Idempotency-Key, carries: [CreateOrder, OrderCreated] }
api_to_orders_db: { from: api, to: orders_db, type: postgres, auth: iam-db-auth, statementTimeout: 1s, connectionPool: 10 }
types:
CreateOrder: { fields: { customerId: { type: uuid, pii: true }, sku: { type: string } } }
OrderCreated: { fields: { orderId: { type: uuid } } }
tables:
orders_db.orders:
columns:
id: { type: uuid, pk: true }
customer_id: { type: uuid, pii: true }
sku: { type: text }
status: { type: text, enum: [PLACED] }
flow:
- id: S1
via: web_to_api
call: POST /orders
headers: { Idempotency-Key: required }
request: CreateOrder
- id: S2
via: api_to_orders_db
transaction:
- insert: orders_db.orders
as: order
map:
id: $uuid
customer_id: request.customerId
sku: request.sku
status: "'PLACED'"
on_conflict: { id: never happens; ids are generated }
- id: S3
via: web_to_api
respond:
status: 201
body:
build: OrderCreated
map: { orderId: order.id }Step details:
- call:
call:is the label (POST /orders,payments.Capture), orendpoint:names an endpoint the callee declares (see API contracts).request:is a type name, or abuild:block that constructs it from data in scope.headers:declares headers the mappings may use, and path parameters in the label ({sku}) can be read aspath.sku.as:renames the request (defaultrequest). - respond:
status:andbody:(a type or abuild:block; leave it out for a response without a body). Addas: replyto make a downstream service's response available to later steps. - transaction: a list of operations:
insert,update,deleteorselect. Each one names its table (insert: db.table), maps columns (map:, not forselectordelete), and may addas:(a name for the row),where:,on_conflict:(what happens on a duplicate key) andwhen:(a condition, documented, not evaluated). Aselectreads a row, such as a cache lookup, and itsas:name makes the columns available to later mappings. - poll:
poll: db.table,as:,where:,every:. - publish:
body:(a type orbuild:) and usuallyfrom: outbox.payload. - consume:
consume: TypeName, pluson_error:andon_unknown_schema_version:(rule P10).as:renames the message (defaultmessage).
Triggers. A request from a caller, a poll and a consume each start a new trigger: a
separate execution. The sequence diagram shows each one under its own grey band. Names in scope
(request, message, row names) only exist within their trigger, the way a variable only
exists inside its function.
Mapping expressions
map: assigns each target field or column from an expression. The checker verifies that every
source exists and that its type fits the target (rules TYP001–TYP004).
| Expression | Meaning |
|---|---|
request.customerId |
A field of the request in scope |
request.items[].sku |
A field of every item in a list |
headers.Idempotency-Key |
A declared header |
path.sku, query.page |
A path or query parameter of the call, typed by its endpoint's contract |
message.orderId |
A field of the consumed message |
order.id |
A column of a row named earlier with as: order |
charge.chargeId |
A field of a response named with as: charge |
"'PLACED'" |
A text literal (single quotes inside a YAML string) |
"1", true |
Number and boolean literals |
$uuid, $now |
A generated id, the current time |
sum(...), count(...), min, max, lower, upper |
Functions over the values above |
build: constructs a whole payload; every non-optional field must be set (rule TYP004):
payload:
build: OrderPlacedEvent
map:
eventId: outbox.id
eventType: "'order.placed'" # must equal the type's const
schemaVersion: "1"
orderId: order.id
itemCount: count(request.items)
Asynchronous flows
Writing to a database and publishing a message in the same request is a dual write: a crash
between the two loses or invents events (rule P8; a transaction that only selects is a read, not
a write). Write the event to an outbox table in the
same transaction, and let a relay publish it:
components:
api: { kind: service, title: Orders API, owns: [db], zone: private }
relay: { kind: worker, title: Outbox Relay, partOf: api, zone: private }
db: { kind: database, title: Orders DB, zone: private }
events: { kind: queue, title: order-events, zone: private }
events-dlq: { kind: queue, title: order-events-dlq, zone: private }
shipping: { kind: service, title: Shipping Worker, owns: [ship_db], zone: private }
ship_db: { kind: database, title: Shipping DB, zone: private }
connections:
relay_to_db: { from: relay, to: db, type: postgres, auth: iam-db-auth, statementTimeout: 2s, connectionPool: 2 }
relay_to_events: { from: relay, to: events, type: sqs-standard, auth: iam-role, encryption: { atRest: SSE-KMS }, carries: [OrderPlaced] }
events_to_shipping: { from: events, to: shipping, type: sqs-standard, auth: iam-role, encryption: { atRest: SSE-KMS }, dlq: events-dlq, visibilityTimeout: 30s, idempotentConsumer: ship_db.seen, carries: [OrderPlaced] }
shipping_to_ship_db: { from: shipping, to: ship_db, type: postgres, auth: secrets-manager, statementTimeout: 2s, connectionPool: 5 }
types:
OrderPlaced: { fields: { eventId: { type: uuid }, orderId: { type: uuid } } }
tables:
db.outbox:
columns:
id: { type: uuid, pk: true }
payload: { type: jsonb, schema: OrderPlaced }
published_at: { type: timestamptz, nullable: true }
ship_db.seen: { columns: { event_id: { type: uuid, pk: true } } }
ship_db.shipments: { columns: { id: { type: uuid, pk: true }, order_id: { type: uuid } } }
flow:
- { id: S1, via: relay_to_db, poll: db.outbox, as: outbox, where: published_at IS NULL, every: 1s }
- { id: S2, via: relay_to_events, publish: { body: OrderPlaced, from: outbox.payload } }
- id: S3
via: relay_to_db
transaction:
- { update: db.outbox, where: id = outbox.id, map: { published_at: $now } }
- { id: S4, via: events_to_shipping, consume: OrderPlaced, on_error: retry then dead-letter queue }
- id: S5
via: shipping_to_ship_db
transaction:
- { insert: ship_db.seen, map: { event_id: message.eventId }, on_conflict: { event_id: already handled; ack and skip } }
- { insert: ship_db.shipments, map: { id: $uuid, order_id: message.orderId } }Queues deliver at least once, so consumers see duplicates. The first write after consume
must be an insert into the idempotentConsumer table with on_conflict (rule P4).
Scenarios and invariants
invariants:
- at most one shipment per order, even with duplicate delivery
scenarios:
- name: duplicate delivery
tags: [duplicate-delivery] # every consumer needs one of these (rule COV002)
covers: [S4, S5] # steps it exercises (rule COV001: every step covered)
when: the same OrderPlaced arrives twice
then: [1 shipment, both messages acknowledged]
Scenarios become the acceptance tests in agent briefs.
Findings and how to fix them
Errors (✗) should be fixed. Warnings (!) are probably mistakes. Info (i) is advisory. Click a finding to jump to its line.
components:
web: { kind: external, title: Web App, zone: internet }
api: { kind: service, title: API, zone: dmz }
data: { kind: database, title: Data, zone: private }
connections:
web_to_api: { from: web, to: api, type: https-rest, timeout: 3s, retryPolicy: none }
api_to_data: { from: api, to: data, type: postgres, auth: password, statementTimeout: 1s }- ✗
P2web_to_api crosses trust boundary internet->dmz without authentication - ✗
NET003web_to_api is a public endpoint (web -> api) but documents no edgeProtection (WAF, rate limiting, captcha) - ✗
P11api_to_data: auth 'password' is not allowed for postgres (allowed: iam-db-auth, secrets-manager) - ✗
P3api_to_data (postgres) is missing required connectionPool - ✗
P7api_to_data: api accesses data, which is owned by nobody; go through the owner's API or events
| Rule | Checks | Usual fix |
|---|---|---|
| SCH001 | The file is valid YAML with known keys | Fix the key or indentation named in the message |
| REF001 | Every name used refers to something that exists | Fix the spelling or add the missing item |
| TYP001 | Mapping targets exist | Use a column or field the table or type has |
| TYP002 | Mapping sources exist in scope | Follow the "did you mean" hint, or check which trigger the step is in |
| TYP003 | Types, enums and constants fit | Map a compatible value |
| TYP004 | Built payloads set every field | Map the missing fields, or mark them optional |
| DAT001 | Inserts set every NOT NULL column | Map the column, give it a default, or make it nullable |
| DAT002 | Columns something writes to | Remove dead columns or add the step that writes them |
| DAT003 | Event fields a consumer reads | Advisory: remove fields nobody uses |
| DAT004 | Data labels come from the catalog | Use a label from data-labels.yaml |
| P1 | Sensitive data is encrypted in transit and at rest | Set encryption: { atRest: … } |
| P2 | Boundary crossings authenticate | Set auth, or record an exception |
| P3 | Properties the connection type requires are set | Add them (timeouts, pools, DLQ …) |
| P4 | At-least-once consumers skip duplicates | Add idempotentConsumer and insert into it first with on_conflict |
| P5 | Payloads travel over connections that declare them | Add the type to carries |
| P6 | Downstream timeouts fit the caller's timeout: the direct calls made before the respond, each costing the larger of its own timeout and its own downstream calls |
Raise the entry timeout or lower downstream ones |
| P7 | Only the owner touches a database | Go through the owner's API or events |
| P8 | No dual writes | Use an outbox and a relay |
| P9 | No synchronous call cycles | Make one direction asynchronous |
| P10 | Consumers handle errors and unknown versions | Add on_error and on_unknown_schema_version |
| P11 | Auth method is allowed by the connection type | Pick one from the type's list |
| NET001 | Encryption in transit everywhere | Use an encrypted connection type |
| NET002 | Outbound calls go to approved vendors with allowed data | Set vendor, or stop sending that data |
| NET003 | Public endpoints document edge protection | Set edgeProtection |
| NET004 | Declared boundaries match zones | Fix or remove boundary |
| NET005 | Every connection has a port | Set port |
| COV001 | Every step is in a scenario | Add it to a scenario's covers |
| COV002 | Every consumer has a duplicate-delivery scenario | Add one tagged duplicate-delivery |
| SUP001 | Exceptions cite an ADR | Put ADR-123 in the reason |
| UNU001 | Components are connected (a container counts if one of its parts is) | Connect or remove it |
| DES001 | High-level links have a design under them (info) | Add typed connections between the components inside the two ends |
| CON001 | Calls use an endpoint the callee declares | Pick the endpoint with endpoint:, or add the call to the callee's api |
| CON002 | Requests, responses and parameters match the contract | Send the declared type, respond with a declared status and body, or change the contract |
| CON003 | Messages on a queue are ones it declares | Publish a declared type, or add it to the queue's messages |
| CON004 | Deprecated endpoints, messages and fields are not used (warning) | Move to what the deprecation note says |
| CON005 | Declared endpoints are called by a step (info) | Add the step that calls it, or remove the endpoint |
| REV001 | Reviews match a current finding (info) | The finding was fixed or moved: remove the review |
| REV002 | Findings marked done say why | Add a comment to the review |
The rule pack lives in platform/policies.yaml; the Platform tab shows the one in use.
Reviewing findings
Each finding on the Findings tab (and under Findings here in the diagram panel) can be triaged:
| Button | Means | Counts as open? |
|---|---|---|
| Mark reviewed | Seen and agreed; still to do. Add a note with Edit note. | Yes, with a Reviewed badge |
| Mark done… | Handled, with a comment saying how or why. A comment is required. | No: listed under Done |
| Reopen | Remove the review | Yes |
Reviews are part of the spec, in a reviews section, so they travel with the file, show up in
code review, and the command line and SARIF output report them:
reviews:
- rule: DES001
at: connections/order-processing-engine_to_warehouse # where the finding is
status: reviewed
comment: Warehouse integration is designed next quarter.
by: Dana
date: 2026-10-20
- rule: DAT003
at: types/OrderPlacedEvent
status: done
comment: eventType is kept for routing and logs.
at names the item (components/orders-api, connections/client_to_orders-api/timeout, flow/S4/request), using
step ids rather than positions, so reordering the flow keeps reviews attached; renaming a
component or a step updates them too. When the finding goes away, the review is reported by
REV001 so you can remove it. A done finding no longer fails archspec check; a reviewed one
still does.
Reviews and exceptions differ: an exception (below) is a policy decision recorded with an ADR and attached to the item itself; a review is day-to-day triage.
Exceptions (suppressions)
Sometimes a rule doesn't apply, such as a public sign-up page that can't require a login. Record the exception where it applies, with the decision record that approved it:
connections:
visitor_to_signup-api:
from: visitor
to: signup-api
auth: none
suppress:
P2: "public sign-up endpoint; captcha and rate limits at the edge, see ADR-012"
Suppressed findings stay visible (marked ~) but don't count as errors, and the Boundaries tab
lists them as exceptions with their ADR. A reason without an ADR- reference is itself a
warning (SUP001). suppress works on components, connections, types, tables and steps.
Moving between levels
| Do this | To |
|---|---|
| Double-click a box, or click open › | Zoom into it. The diagram zooms in from that box. |
| Esc (with nothing selected), Alt+↑, or ↑ Up a level | Zoom out. The box you left flashes so you can see where you were. |
| Select a box, then Enter or Alt+↓ | Zoom into the selected box |
| Click a level in the bar above the diagram | Jump straight to it. The Level n chip says how deep you are. |
| Click the You are here map (bottom right) | Go up to the level it shows; the box you are inside is highlighted on it |
| Click a box in the System map (side panel) | Jump to any level. A box with nothing inside is selected on its parent's level. |
| Ctrl/Cmd + scroll, or pinch | Zoom the picture in and out (Fit, − and + do the same) |
The Architecture, Sequence and API tabs share the level: open a system on one and the others show the same place.
Clicking shows the code. Click a box, a line, a step, a type, an endpoint on the API tab or a row of the Connections table, and the spec editor scrolls to where it is written and highlights those lines. Your keyboard stays on the diagram, so Delete and the arrow keys still act on the selection.
Clicking the code shows the picture. Click a line of the spec, or move to it with the arrow keys, and the item it belongs to is selected and brought into view: a component or connection on the Architecture tab (opening the level where it is drawn), a step on the Sequence tab, an endpoint on the API tab, a finding on the Findings tab, a row on the Connections tab. It flashes so you can find it. Typing doesn't move anything; only clicks and arrow keys do.
API reference
Every box has an API reference on the API tab, worked out from the flow, or from the box's declared contract when it has one (see API contracts). Pick a box in the list on the left (it follows the level you are on).
- Endpoints: one per call the box receives (
call: POST /orders), or per endpoint it declares. Each shows who calls it, over which connection, with what auth, timeout and trust boundary; the path and header parameters; the request body schema with sensitivity flags and an example; the responses (from therespondsteps); the error responses; and the steps that implement it. - Message handlers, Publishes and Scheduled jobs: the
consume,publishandpollsteps, with delivery, dead-letter and dedupe details. - Calls to other boxes: what this box depends on.
For a system, endpoints called from outside it are listed as public and the rest as internal.
Document an endpoint on its call step. description becomes the summary and errors lists
error responses by status code:
flow:
- id: S1
via: customer_to_storefront
call: POST /checkout
description: Turns the saved cart into an order. Safe to retry with the same Idempotency-Key.
headers: { Idempotency-Key: required }
request: CheckoutRequest
errors: { 404: no saved cart with that id, 409: the Idempotency-Key was used for a different cart }
Give fields a description in types and they appear in the schema tables and the OpenAPI.
Download OpenAPI saves an OpenAPI 3.1 file for the box's HTTP endpoints (gRPC endpoints
are in the reference but not in OpenAPI), and Download AsyncAPI an AsyncAPI 3.0 file for
the messages it publishes and consumes. Open it in Swagger UI, Postman, Redocly or a code
generator. Auth maps to security schemes (oauth2-jwt → bearer JWT, api-key, hmac-signature,
mtls-identity → mutual TLS), data labels become x-data-labels, and the callers are listed
in x-callers. Copy as Markdown copies the reference for a ticket or a wiki page.
API contracts
The API tab works out each box's endpoints from the flow. To fix a box's API before the flow is
written, or to hold every caller to it, declare it: give the component an api with one
entry per endpoint, named the way code would call it.
components:
game: { kind: service, title: Game Service, zone: dmz }
board:
kind: service
title: Leaderboard
zone: private
apiVersion: 1.0.0 # raise the major number for a breaking change
api:
getRank:
call: GET /v1/scores/{userId}
description: A player's score and rank this month.
path: { userId: uuid }
query: { around: { type: int, optional: true, description: players shown above and below } }
responses:
200: Rank # a type: the response body
404: no score this month # text: a response without a body
connections:
game_to_board: { from: game, to: board, type: https-rest, auth: oauth2-jwt, timeout: 1s, retryPolicy: safe to retry, carries: [Rank] }
types:
Rank: { fields: { userId: { type: uuid }, score: { type: int }, rank: { type: int } } }
flow:
- { id: S1, via: game_to_board, endpoint: getRank }
- id: S2
via: game_to_board
respond:
status: 200
body: { build: Rank, map: { userId: path.userId, score: "0", rank: "1" } }An endpoint has:
| Key | Meaning |
|---|---|
call |
GET /v1/scores/{userId} for HTTP, or an RPC name such as leaderboard.GetRank (required) |
description |
Shown in the API reference and OpenAPI |
path, query, headers |
Parameters by name: a type (uuid), or { type, optional, description, enum, deprecated }. Every {name} in the path is a path parameter. |
request |
The request body type |
responses |
By status (200, 404, or an RPC status such as NOT_FOUND): a type name for the body, or a description for a response without one |
deprecated |
true, or what to use instead |
A flow step uses an endpoint with endpoint: getRank. Its call label and request come from the
contract, and its mappings can read the parameters as path.userId, query.around and
headers.Name, typed as declared. A step whose call label equals a declared one is matched
too, so existing flows pick up a contract without changes.
Queues and topics declare the messages they carry, with the field used as the key and a schema version:
components:
order-events:
kind: queue
messages:
OrderPlacedEvent: { key: orderId, version: 1, description: one per placed order }
What is checked:
| Rule | Checks |
|---|---|
| CON001 | A call to a component with an api uses one of its endpoints (by endpoint: or by label) |
| CON002 | The step sends the declared request, responds with a declared status and body; the path parameters match the path; parameters are plain values |
| CON003 | Publish and consume steps on a queue with messages use one of them |
| CON004 | Deprecated endpoints, messages and fields are not used (warning) |
| CON005 | Every declared endpoint is called by some step (info: fine while you design ahead) |
From the flow to a contract. On the API tab, Declare as contract writes the endpoints a
box serves according to the flow into its api, with parameters, request, responses and the
errors documented on the steps (which move into the contract). Calls the contract doesn't
cover are marked not in contract, with Add to the contract. In the step panel, a call to
a box with a contract picks its endpoint from a list.
Versions and breaking changes. Give the component an apiVersion. When you change a
contract, the API tab lists the contract changes since you last saved: which are breaking
(a removed endpoint or response field, a new required parameter or request field, a changed
type, a changed message key) and which are compatible (new endpoints, new optional fields). A
breaking change needs a new major apiVersion, or the old contract kept working. In CI, compare
the spec with the version on the main branch:
git show main:my.spec.yaml > /tmp/before.spec.yaml
node cli/archspec.mjs diff /tmp/before.spec.yaml my.spec.yaml # exit code 1 on an unversioned breaking change
Contracts flow into the exports: OpenAPI gets typed path, query and header parameters, every
declared response, deprecated and the apiVersion; Download AsyncAPI (AsyncAPI 3.0) on
the API tab describes the messages a box publishes and consumes, with their keys and versions.
Reading the diagrams
Architecture:
- Bands are trust zones. Callers are on the left and third parties on the right. Inside a system or component, the bands on the sides hold what is outside it. Boxes follow the data from left to right, so a worker that polls a table (an outbox relay) sits after whatever writes it.
- Thick blue lines are business links. Stacked boxes have more inside them.
- Solid lines are synchronous calls; dashed lines are asynchronous messages; dotted grey lines are dead-letter routes.
- Orange lines carry data that must be encrypted. The label shows the protocol, port and data
labels, plus the connection id when it is a short one such as
C1(descriptive ids are in the tooltip, since the line already joins the two named boxes). - Inside a system or component, its name and kind are on top of a frame around its contents. Its own connections that no flow step uses yet start from a slim bar on the frame's edge.
- Hover a line for its encryption, auth, boundary and payloads.
Sequence:
- One column per participant, in order of first appearance (while draft messages are shown, in call order from the callers instead).
- Solid arrows are calls and database statements, dotted arrows are responses, and dashed arrows are asynchronous messages. Orange arrows carry sensitive data.
- Grey bands mark a new trigger. Yellow notes show error handling and conflict rules.
- Dashed rounded labels stand for steps inside one box; grey dashed arrows are draft messages from the architecture that no step uses yet.
Both diagrams have a legend, and Fit / − / + controls zoom.
Editing the diagrams
Both diagrams are editors. Everything you do on them is written into the YAML on the left, keeping your comments, so the spec stays the source of truth. The panel on the right edits whatever is selected and lists the findings for it, so you see straight away what is missing.
Architecture
| Do this | To |
|---|---|
| Drag the ● on the right of a box onto another box | Connect them. The connection type is suggested from what you connect (a database gets postgres, a queue sqs-standard, an external party https-rest). |
| Drag a box onto another zone band | Move it to that zone. Trust boundaries update. |
| Double-click a box | Zoom into it if it has more inside; otherwise rename its title (F2 renames any selected box) |
| Double-click empty space in a zone | Add a component there |
| Click a box or a line | Edit it in the panel: kind, zone, owned databases, vendor; or connection type, auth, port, timeouts, encryption, the data it carries |
| Click open › on a box | Look inside it. New components you add there are placed inside it (partOf). |
| Click API on a box | Open its API reference on the API tab. Boxes that serve endpoints (something in the flow calls them), or have parts that do, show the badge. |
| Drag between a person and a system, or two systems | Add a link. Give it a label in the panel, such as “submits orders”. |
| Add component, Add connection, Add data type | The same, from forms |
Sequence
| Do this | To |
|---|---|
| Drag from one lifeline to another | Add a step where you let go. The kind follows from the target: a database gets a transaction, a queue a publish, a queue's consumer a consume, and dragging back to the caller adds a response. If there's no connection between them yet, one is created. |
| Click a dashed draft message | Add it to the flow as a step over that connection |
| Drag a message up or down | Reorder the flow |
| Double-click a call | Rename its label |
| Click a message | Edit it in the panel: the connection, the payload, and the mappings |
The step panel's mapping editor lists every field or column of the target. As you type, it
suggests what is in scope and fits the type: a uuid column is offered message.orderId
and $uuid, a column with allowed values is offered those values as literals. A transaction
can have several operations; later operations can use rows named by earlier ones.
Everywhere: Delete removes the selection, Ctrl/Cmd+Z undoes, Esc clears the selection, and Hide spec code gives the diagram the full width. On any tab, « Hide in the Spec header does the same for every view; Show spec » on the left edge brings the code back, and so does clicking a finding. Use Fit to see the whole diagram while you drag between distant boxes. Tables are edited in the spec code.
Security review
- Connections: the ports and protocols matrix. Each row shows direction (inbound, outbound, internal), mode, protocol, port, encryption, auth, trust boundary, timeout, and the data carried with its description and sensitivity flags.
- Boundaries: only the connections that cross zones, grouped by boundary, with edge protection, vendor (and its data agreement) and approved exceptions.
Copy either as Markdown for a review ticket, or as CSV for a spreadsheet.
Saving, opening and exporting
- New starts a blank spec (asking first if you'd lose unsaved work).
- Your work is kept in this browser automatically between visits.
- Save (Ctrl/Cmd+S) writes a
.spec.yamlfile. In Chrome and Edge it keeps writing to the same file; other browsers download a copy. - Open… reads a spec from your computer. You can also drop a file onto the editor.
- Export downloads diagrams as SVG or PNG (at the level you are looking at), the connections matrix as CSV, the security tables and the API reference as Markdown, and the model as Mermaid or LikeC4. OpenAPI and AsyncAPI for one box are on the API tab.
Nothing is uploaded: the app makes no network requests after the page loads.
Agent briefs
The Agent brief tab cuts the spec down to what one or more steps need: the steps themselves, their connections, payload types, tables, invariants, the rules that apply, and the scenarios that cover them as acceptance tests. Give it to a developer or a coding agent with the instruction to implement those steps; the brief tells them to change the spec rather than work around it.
Platform catalogs
Specs import shared catalogs, usually owned by a platform or security team:
| File | Holds |
|---|---|
connection-types.yaml |
Approved connection patterns: protocol, port, encryption, auth, required properties |
vendors.yaml |
Approved third parties and the data labels each may receive |
data-labels.yaml |
Sensitivity labels and which must be encrypted |
policies.yaml |
The rule pack: rule ids, severities, descriptions |
The web app includes the standard catalogs; the Platform tab shows them.
Keyboard shortcuts
| Keys | Action |
|---|---|
| Ctrl/Cmd+S | Save the spec |
| Ctrl/Cmd+Z | Undo the last change made on a diagram |
| Delete | Remove the selected component, connection, type or step |
| Esc | Clear the selection, cancel connecting, or (with nothing selected) zoom out a level |
| Alt+↑ / Alt+↓ or Enter | Zoom out of this level / into the selected box |
| Double-click | Zoom into a box that has more inside; rename a box that doesn't, or a call; add a component in empty zone space |
| F2 | Rename the selected box |
| Ctrl/Cmd + scroll | Zoom the diagram |
| Tab | Indent two spaces in the editor |
Command line
The same engine runs in Node for CI and scripts:
node cli/archspec.mjs check my.spec.yaml # exit code 1 on errors
node cli/archspec.mjs check my.spec.yaml --format sarif # code-scanning annotations
node cli/archspec.mjs render my.spec.yaml all --out out # every view, SVG diagrams, design doc
node cli/archspec.mjs render my.spec.yaml architecture-svg --focus orders-api # one level down
node cli/archspec.mjs render my.spec.yaml openapi --out out # OpenAPI 3.1 per box with HTTP endpoints
node cli/archspec.mjs render my.spec.yaml asyncapi --out out # AsyncAPI 3.0 per box that sends or receives messages
node cli/archspec.mjs diff before.spec.yaml after.spec.yaml # contract changes; exit code 1 if breaking without a new major apiVersion
node cli/archspec.mjs render my.spec.yaml api # Markdown API reference
node cli/archspec.mjs task my.spec.yaml S5 # agent brief for step S5