archspec

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 }
examplePEOPLE · CALLERSOUR SYSTEMSEXTERNAL · THIRD PARTIEScustomer_to_website: customer → website · places orders (not designed yet)website_to_orders: website → orders · submits orders (not designed yet)orders_to_warehouse: orders → warehouse · sends picking lists (not designed yet)customer (person)Customerpersonwebsite (system)eCommerce Websitesystemorders (system)Order ProcessingEnginesystemwarehouse (external)Warehouse Partnerexternalcustomer_to_website: customer → website · places orders (not designed yet)places ordersnot designed yetwebsite_to_orders: website → orders · submits orders (not designed yet)submits ordersnot designed yetorders_to_warehouse: orders → warehouse · sends picking lists (not designed yet)sends pickinglistsnot designed yetLEGENDNot designed yetSynchronous callAsynchronous messageCarries sensitive data (PII, PCI, …)Dead-letter routePersonSystemExternal partyServiceWorkerDatabaseQueueTrust zone
examplePEOPLE · CALLERSOUR SYSTEMSEXTERNAL · THIRD PARTIEScustomer_to_website: customer → website · places orders (not designed yet)website_to_orders: website → orders · submits orders (not designed yet)orders_to_warehouse: orders → warehouse · sends picking lists (not designed yet)customer (person)Customerpersonwebsite (system)eCommerce Websitesystemorders (system)Order ProcessingEnginesystemwarehouse (external)Warehouse Partnerexternalcustomer_to_website: customer → website · places orders (not designed yet)places ordersnot designed yetwebsite_to_orders: website → orders · submits orders (not designed yet)submits ordersnot designed yetorders_to_warehouse: orders → warehouse · sends picking lists (not designed yet)sends pickinglistsnot designed yetLEGENDNot designed yetSynchronous callAsynchronous messageCarries sensitive data (PII, PCI, …)Dead-letter routePersonSystemExternal partyServiceWorkerDatabaseQueueTrust zone

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 }
exampleEXTERNAL · CALLERSweb_to_api: web → api · HTTPS 443 · sync encryption TLS1.2+ · auth oauth2-jwtapi_to_orders_db: api → orders_db · PostgreSQL 5432 · sync encryption TLS1.2+, at rest KMS · auth iam-db-authweb (external)Web Appexternalapi (service) owns orders_dbOrders APIserviceorders_db (database)Orders DBpostgresweb_to_api: web → api · HTTPS 443 · sync encryption TLS1.2+ · auth oauth2-jwtHTTPS 443api_to_orders_db: api → orders_db · PostgreSQL 5432 · sync encryption TLS1.2+, at rest KMS · auth iam-db-authPostgreSQL 5432LEGENDSynchronous callAsynchronous messageCarries sensitive data (PII, PCI, …)Dead-letter routeExternal partyServiceWorkerDatabaseQueueTrust zone
exampleEXTERNAL · CALLERSweb_to_api: web → api · HTTPS 443 · sync encryption TLS1.2+ · auth oauth2-jwtapi_to_orders_db: api → orders_db · PostgreSQL 5432 · sync encryption TLS1.2+, at rest KMS · auth iam-db-authweb (external)Web Appexternalapi (service) owns orders_dbOrders APIserviceorders_db (database)Orders DBpostgresweb_to_api: web → api · HTTPS 443 · sync encryption TLS1.2+ · auth oauth2-jwtHTTPS 443api_to_orders_db: api → orders_db · PostgreSQL 5432 · sync encryption TLS1.2+, at rest KMS · auth iam-db-authPostgreSQL 5432LEGENDSynchronous callAsynchronous messageCarries sensitive data (PII, PCI, …)Dead-letter routeExternal partyServiceWorkerDatabaseQueueTrust zone

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 }
exampleINTERNET · CALLERSDMZPRIVATEINTERNET · THIRD PARTIESweb_to_api: web → api · HTTPS 443 · sync encryption TLS1.2+ · auth oauth2-jwt · crosses internet->dmzapi_to_db: api → db · PostgreSQL 5432 · sync encryption TLS1.2+, at rest KMS · auth iam-db-auth · crosses dmz->privateapi_to_mail: api → mail · HTTPS 443 · sync encryption TLS1.2+ · auth api-key · crosses dmz->internetweb (external) zone internetWeb Appexternalapi (service) owns db zone dmzProfile APIservicedb (database) zone privateProfile DBdatabasemail (external) zone internetEmail Providervendor · relay-mailweb_to_api: web → api · HTTPS 443 · sync encryption TLS1.2+ · auth oauth2-jwt · crosses internet->dmzHTTPS 443api_to_db: api → db · PostgreSQL 5432 · sync encryption TLS1.2+, at rest KMS · auth iam-db-auth · crosses dmz->privatePostgreSQL 5432api_to_mail: api → mail · HTTPS 443 · sync encryption TLS1.2+ · auth api-key · crosses dmz->internetHTTPS 443LEGENDSynchronous callAsynchronous messageCarries sensitive data (PII, PCI, …)Dead-letter routeExternal partyServiceWorkerDatabaseQueueTrust zone
exampleINTERNET · CALLERSDMZPRIVATEINTERNET · THIRD PARTIESweb_to_api: web → api · HTTPS 443 · sync encryption TLS1.2+ · auth oauth2-jwt · crosses internet->dmzapi_to_db: api → db · PostgreSQL 5432 · sync encryption TLS1.2+, at rest KMS · auth iam-db-auth · crosses dmz->privateapi_to_mail: api → mail · HTTPS 443 · sync encryption TLS1.2+ · auth api-key · crosses dmz->internetweb (external) zone internetWeb Appexternalapi (service) owns db zone dmzProfile APIservicedb (database) zone privateProfile DBdatabasemail (external) zone internetEmail Providervendor · relay-mailweb_to_api: web → api · HTTPS 443 · sync encryption TLS1.2+ · auth oauth2-jwt · crosses internet->dmzHTTPS 443api_to_db: api → db · PostgreSQL 5432 · sync encryption TLS1.2+, at rest KMS · auth iam-db-auth · crosses dmz->privatePostgreSQL 5432api_to_mail: api → mail · HTTPS 443 · sync encryption TLS1.2+ · auth api-key · crosses dmz->internetHTTPS 443LEGENDSynchronous callAsynchronous messageCarries sensitive data (PII, PCI, …)Dead-letter routeExternal partyServiceWorkerDatabaseQueueTrust zone

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 }
ConnFrom → ToDirectionModeProtocolPortIn transitAt restAuthBoundaryTimeoutData
web_to_apiweb → apiinboundsyncHTTPS443TLS1.2+—oauth2-jwtinternet->dmz3sProfileUpdate: 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, respond bodies come back from it. A connection no step uses is checked against its carries.
  • 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 }
example: sequenceweb (external)Web Appapi (service)Orders APIorders_db (database)Orders DBRequest · Web App calls POST /ordersS1 via web_to_api: web → api carries PII dataS1 POST /orders (CreateOrder)S2 via api_to_orders_db: api → orders_db carries PII dataS2 TX: INSERT ordersorders on conflict: id: neverhappens; ids are generatedS3 via web_to_api: api → webS3 201 OrderCreatedLEGENDSynchronous call or DB statementResponseAsynchronous messageCarries sensitive data (PII, PCI, …)Error handling / conflict ruleNew trigger (separate execution)
example: sequenceweb (external)Web Appapi (service)Orders APIorders_db (database)Orders DBRequest · Web App calls POST /ordersS1 via web_to_api: web → api carries PII dataS1 POST /orders (CreateOrder)S2 via api_to_orders_db: api → orders_db carries PII dataS2 TX: INSERT ordersorders on conflict: id: neverhappens; ids are generatedS3 via web_to_api: api → webS3 201 OrderCreatedLEGENDSynchronous call or DB statementResponseAsynchronous messageCarries sensitive data (PII, PCI, …)Error handling / conflict ruleNew trigger (separate execution)

Step details:

  • call: call: is the label (POST /orders, payments.Capture), or endpoint: names an endpoint the callee declares (see API contracts). request: is a type name, or a build: 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 as path.sku. as: renames the request (default request).
  • respond: status: and body: (a type or a build: block; leave it out for a response without a body). Add as: reply to make a downstream service's response available to later steps.
  • transaction: a list of operations: insert, update, delete or select. Each one names its table (insert: db.table), maps columns (map:, not for select or delete), and may add as: (a name for the row), where:, on_conflict: (what happens on a duplicate key) and when: (a condition, documented, not evaluated). A select reads a row, such as a cache lookup, and its as: name makes the columns available to later mappings.
  • poll: poll: db.table, as:, where:, every:.
  • publish: body: (a type or build:) and usually from: outbox.payload.
  • consume: consume: TypeName, plus on_error: and on_unknown_schema_version: (rule P10). as: renames the message (default message).

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 } }
example: sequencerelay (worker)Outbox Relaydb (database)Orders DBevents (queue)order-eventsshipping (service)Shipping Workership_db (database)Shipping DBScheduled · Outbox Relay polls outbox every 1sS1 via relay_to_db: relay → dbS1 poll outbox every 1sS2 via relay_to_events: relay → eventsS2 publish OrderPlacedS3 via relay_to_db: relay → dbS3 TX: UPDATE outboxMessage · Shipping Worker receives OrderPlacedS4 via events_to_shipping: events → shippingS4 deliver OrderPlacedon_error: retry then dead-letterqueueS5 via shipping_to_ship_db: shipping → ship_dbS5 TX: INSERT seen, INSERT shipmentsseen on conflict: event_id: alreadyhandled; ack and skipLEGENDSynchronous call or DB statementResponseAsynchronous messageCarries sensitive data (PII, PCI, …)Error handling / conflict ruleNew trigger (separate execution)
example: sequencerelay (worker)Outbox Relaydb (database)Orders DBevents (queue)order-eventsshipping (service)Shipping Workership_db (database)Shipping DBScheduled · Outbox Relay polls outbox every 1sS1 via relay_to_db: relay → dbS1 poll outbox every 1sS2 via relay_to_events: relay → eventsS2 publish OrderPlacedS3 via relay_to_db: relay → dbS3 TX: UPDATE outboxMessage · Shipping Worker receives OrderPlacedS4 via events_to_shipping: events → shippingS4 deliver OrderPlacedon_error: retry then dead-letterqueueS5 via shipping_to_ship_db: shipping → ship_dbS5 TX: INSERT seen, INSERT shipmentsseen on conflict: event_id: alreadyhandled; ack and skipLEGENDSynchronous call or DB statementResponseAsynchronous messageCarries sensitive data (PII, PCI, …)Error handling / conflict ruleNew trigger (separate execution)

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 the respond steps); the error responses; and the steps that implement it.
  • Message handlers, Publishes and Scheduled jobs: the consume, publish and poll steps, 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" } }
example: sequencegame (service)Game Serviceboard (service)LeaderboardRequest · Game Service calls GET /v1/scores/{userId}S1 via game_to_board: game → boardS1 GET /v1/scores/{userId}S2 via game_to_board: board → gameS2 200 RankLEGENDSynchronous call or DB statementResponseAsynchronous messageCarries sensitive data (PII, PCI, …)Error handling / conflict ruleNew trigger (separate execution)
example: sequencegame (service)Game Serviceboard (service)LeaderboardRequest · Game Service calls GET /v1/scores/{userId}S1 via game_to_board: game → boardS1 GET /v1/scores/{userId}S2 via game_to_board: board → gameS2 200 RankLEGENDSynchronous call or DB statementResponseAsynchronous messageCarries sensitive data (PII, PCI, …)Error handling / conflict ruleNew trigger (separate execution)

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.yaml file. 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