# Writing diagc diagrams diagc turns a TypeScript file into a diagram: boxes that nest, arrows between them, and a picture that rests folded until the reader opens a box. You write the structure; diagc validates it, lays it out and draws it. You never write positions. This guide is for diagc 1.1.4. ## The loop 1. Write or edit `.diagrams/src/.diagram.ts`. A folder is allowed: `.diagrams/src/team/app.diagram.ts` is the diagram `team/app`. 2. Run `diagc lint --json`. It prints `[]` and exits 0 when everything is clean. Otherwise each finding has `file`, `severity`, `code` and `message`: fix it and run again. An `error` means the diagram does not compile. A `warning` compiles but is probably a mistake, such as a misspelled type. 3. Run `diagc publish ` and look at `.diagrams/static/.png` to check the picture. It needs Chrome; set `CHROME_PATH` when none is found. The PNG shows the first plane with every group opened and only the layers that plane turns on; other planes and layers are on the page `.diagrams/html/.html`. A person runs `diagc studio` to browse every diagram and to move boxes by hand. Commit `.diagrams/src/` and `.diagrams/static/`. `.diagrams/.artifacts/`, `.diagrams/html/` and `.diagrams/diff/` are build output. ## A diagram ```ts import { model } from '@diagc/core'; // A starter: a customer and one system holding two services and a database. // `contains` nests, `relate` draws an arrow. The system rests folded, with the // arrows into it bundled, until you double-click it. const m = model('shop', { name: 'Shop' }); const customer = m.node('customer', { type: 'person', name: 'Customer' }); const shop = m.node('shop', { type: 'system', name: 'Shop' }); const web = m.node('web', { type: 'service', name: 'Storefront' }); const api = m.node('api', { type: 'service', name: 'Orders API' }); const db = m.node('orders-db', { type: 'database', name: 'Orders DB' }); shop.contains(web, api, db); m.relate(customer, web, { kind: 'sync', label: 'Browses' }); m.relate(web, api, { kind: 'sync', label: 'Places orders' }); m.relate(api, db, { kind: 'writes', label: 'Order rows' }); export default m; ``` - `model(id, { name })` starts a diagram, and `export default m` is what the compiler reads. - `m.node(id, { type, name })` declares a box and returns a handle. Keep the handle: `contains` and `relate` take handles, not ids. - `parent.contains(a, b)` nests. A box with children is a group. It rests folded and shows how many boxes are hidden inside it. Arrows to anything inside it end on the group, merged into one arrow per box at the other end, until it is opened. - `m.relate(from, to, { kind, label })` draws an arrow. `kind` is required. - diagc supplies `@diagc/core` when it compiles, so the import needs no install; `npm i -D @diagc/core@1.1.4` only adds types for your editor. More node options: | Option | Meaning | | --- | --- | | `technology` | Printed under the name, after the type: `service: Go`, or `[Container: Go]` on a C4 node. | | `color` | Accent colour, for example `'#1565c0'`. | | `description` | Shown in the detail panel only, never on the canvas. | | `metadata` | Free-form key and value data. | | `plane`, `layer` | Show the node in one plane only, or put it on a layer. | More relation options: `layer`, `description`, `id`. ## Types and kinds `type` and `kind` are free-form strings. A known one gets its own shape or line style. An unknown one draws as a plain box or a plain arrow, and `lint` reports it as `unknown-type` or `unknown-kind`. Node types for any diagram: `system`, `platform`, `service`, `database`, `aws-rds`, `table`, `queue`, `infra`, `person`, `team`, `comment`, `aws-group`, `aws-account`, `aws-cloud`, `aws-vpc`, `aws-region`, `aws-auto-scaling-group`, `aws-az`, `aws-subnet-public`, `aws-subnet-private` Relation kinds: `sync`, `async`, `reads`, `writes`, `hosted-on`, `flow`, `mixed`, `fk`, `control`, `object-flow`, `interrupt`, `note-link`, `leads-to`, `cause-of`, `data-flow`, `owns`, `executes`, `checks`, `network`, `influence` The general kinds are `sync`, `async`, `reads`, `writes`, `hosted-on` and `flow`. `mixed` is what a bundled arrow gets when the relations it stands for differ in kind; do not write it. The rest belong to one diagram type and are written by its builder or named in its topic. Each diagram type listed at the end adds node types of its own; its topic lists them. ## Planes and layers A **plane** is another way to group the same nodes: by owner in one plane, by where they run in another. A **layer** is an overlay the reader switches on. Anything that is not on a layer always shows. ```ts import { model } from '@diagc/core'; // One model, two groupings of the same services, and an overlay for data flow. const m = model('shop-views', { name: 'Shop: two views' }); m.plane('architecture', { name: 'Architecture' }); // declared first = the default plane m.plane('infra', { name: 'Infrastructure' }); m.layer('data', { name: 'Data flow', tint: '#2e7d32' }); const web = m.node('web', { type: 'service', name: 'Storefront' }); const api = m.node('api', { type: 'service', name: 'Orders API' }); const db = m.node('orders-db', { type: 'database', name: 'Orders DB' }); // A node that groups in one plane only is scoped to it, or it would sit in the // other plane as an empty box. const shop = m.node('shop', { type: 'system', name: 'Shop', plane: 'architecture' }); const cluster = m.node('cluster', { type: 'infra', name: 'Kubernetes', plane: 'infra' }); const managed = m.node('managed-db', { type: 'infra', name: 'Managed Postgres', plane: 'infra' }); // Tag containment with its plane: an untagged edge belongs to the first plane only. shop.contains(web, api, db, { plane: 'architecture' }); cluster.contains(web, api, { plane: 'infra' }); managed.contains(db, { plane: 'infra' }); m.relate(web, api, { kind: 'sync', label: 'Places orders' }); // On a layer: drawn only while the reader has "Data flow" switched on. m.relate(api, db, { kind: 'writes', label: 'Order rows', layer: 'data' }); export default m; ``` - The first plane declared is the default. Containment without `{ plane }` belongs to it, not to every plane. - A node with no containment in a plane still appears there, as a loose box. Give a grouping node a `plane` so that it shows only where it groups. - `m.plane(id, { containmentOf: 'other' })` reuses another plane's grouping. `layers: ['data']` switches those layers on by default in that plane. - A layer on a relation never moves boxes. A node on a layer appears and disappears with its layer. ## Rules that catch people - Calling `m.node` twice with one id is an error (`duplicate-node`). Declare a node once and keep the handle. - `m.relate(a, 'b-id', …)` does not work. Pass the handle that `m.node` returned. - Put what the reader must see in `name`. `description` is not drawn. - Keep a relation `label` to a short phrase. It is cut at about 24 characters. - Ids must be unique in the diagram. Use short lowercase words joined by hyphens. - Do not write positions or sizes. Layout is automatic, and a person adjusts it in the studio. - For a large system, nest deeper instead of adding boxes at the top level. The reader opens what they need. ## Diagram types Run `diagc guide ` before writing one of these. Some are switched on with `m.notation('')`, some come with a builder (`m.gitGraph()`, `m.plan()`, …), and the topic says which. - `diagc guide activity` — Activity - `diagc guide c4` — C4 - `diagc guide causal-loop` — Causal loop - `diagc guide deployment` — Deployment - `diagc guide er` — ER - `diagc guide fishbone` — Fishbone - `diagc guide git-graph` — Git graph - `diagc guide plan` — Plan - `diagc guide second-order` — Second-order thinking - `diagc guide threat-model` — Threat model `diagc guide all` prints this page and every topic. ## More The full reference is at https://github.com/Ferroman/diagc/tree/v1.1.4/docs: `reference/builder-api.md` for every method and option, `reference/model.md` for every validation and lint code. --- # Activity A UML activity diagram: swimlanes of actions, decisions, forks and joins, and signals between actors. Reach for it to show who does what in a workflow. ```ts import { model } from '@diagc/core'; const m = model('expense-approval', { name: 'Expense approval' }); const act = m.activity('claim', { name: 'Expense claim' }); const finance = act.lane('finance', { name: 'Finance', color: '#3caea3' }); const start = finance.start(); const check = finance.action('check-receipts', 'Check the receipts'); const withinPolicy = finance.decision('within-policy'); const reimburse = finance.action('reimburse', 'Reimburse the claim'); const end = finance.end(); // A guard is just a relation label. act .flow(start, check) .flow(check, withinPolicy) .flow(withinPolicy, reimburse, '[within policy]') .flow(withinPolicy, end, '[over the limit]') .flow(reimburse, end); export default m; ``` ## How it works - `m.activity(id, { name })` declares a frame. Call it again for another frame: each call is one frame, and several share a canvas. It needs no `m.notation` call. - `act.lane(id, { name, color })` adds a lane to the frame. Lanes are drawn top to bottom in the order you declare them. - Elements are created on a lane and return a handle you can wire up: `.action(id, name)`, `.decision(id?, name?)`, `.bar(id?)` for a fork or join, `.start(id?)`, `.end(id?)`, `.send(id, name)`, `.receive(id, name)`, `.object(id, name)` and `.note(id, text)`. - `lane.region(id?, name?)` adds an interruptible region inside a lane. It takes the same element methods, but not another `region`. - `act.flow(from, to, label?)` draws control flow. `act.objectFlow` draws a dashed arrow for data. `act.interrupt` leaves a region. `act.noteLink(note, target)` ties a note to what it annotates. Each returns `act`, so they chain. - A guard is a label: `act.flow(decision, next, '[approved]')`. ## Node types `activity-frame`, `activity-lane`, `activity-region`, `activity-action`, `activity-decision`, `activity-bar`, `activity-start`, `activity-end`, `activity-send`, `activity-receive`, `activity-object`, `activity-note` The builder creates all of these: use the method that matches the shape you want, not `m.node`. ## Rules - A frame's children must be lanes, or validation fails with `activity-frame-children`. - A lane must sit in a frame (`activity-lane-parent`). A region must sit in a lane if it sits in anything (`activity-region-parent`). The builder already does this. - Bars, start and end ignore `color`. - Nothing checks the flow itself: a fork that never joins, or a decision with one exit, is accepted. --- # C4 Context, container and component diagrams with the C4 look: a solid fill for each kind of element, and a `[Type: technology]` line under each name. ```ts import { model } from '@diagc/core'; // A C4 starter: one person, one system, two containers. The notation is what // paints the solid fills. const m = model('expense-claims', { name: 'Expense claims' }); m.notation('c4'); const employee = m.node('employee', { type: 'c4-person', name: 'Employee' }); const claims = m.node('claims', { type: 'c4-system', name: 'Expense Claims' }); const web = m.node('web', { type: 'c4-container-web', name: 'Web Application', technology: 'TypeScript, Next.js' }); const db = m.node('db', { type: 'c4-container-db', name: 'Claims Database', technology: 'PostgreSQL 16' }); claims.contains(web, db); m.relate(employee, web, { kind: 'sync', label: 'Submits claims [HTTPS]' }); m.relate(web, db, { kind: 'writes', label: 'Stores claims [SQL/TCP]' }); export default m; ``` ## How it works - `m.notation('c4')` turns the fills on. Without it the same nodes draw as outlines, and a person as a plain box. - Elements are ordinary `m.node` calls with a `c4-*` type. There is no C4 builder. - The levels are containment: a system `contains` its containers, and a container its components. One model holds every level, and the reader drills in. - `technology` fills the subtitle. `technology: 'Java, Spring Boot'` on a `c4-container-api` prints `[Container: Java, Spring Boot]`. - Put the protocol in the relation `label`: `'Calls [JSON/HTTPS]'`. ## Node types `c4-person`, `c4-person-external`, `c4-system`, `c4-system-external`, `c4-enterprise-boundary`, `c4-system-boundary`, `c4-group`, `c4-container`, `c4-container-external`, `c4-container-web`, `c4-container-spa`, `c4-container-mobile`, `c4-container-desktop`, `c4-container-api`, `c4-container-function`, `c4-container-cli`, `c4-container-db`, `c4-container-blob`, `c4-container-search`, `c4-container-queue`, `c4-container-boundary`, `c4-component`, `c4-component-external`, `c4-component-db`, `c4-component-queue`, `c4-deployment-node`, `c4-infrastructure-node`, `c4-container-instance`, `c4-class`, `c4-interface`, `c4-enum` Pick the most specific one: `c4-container-db` for a database, `c4-container-queue` for a queue, `c4-system-external` for a system you do not own. ## Rules - Do not set `color` on a C4 node. An explicit colour replaces the notation's fill. - Relate the elements that actually talk, component to component. Folded, those arrows bundle onto the containers, so the context view still reads. - Use the general relation kinds, most often `sync`, `async`, `reads` and `writes`. --- # Causal loop Variables that push each other around in circles. Every link is signed, and every closed ring of links is a feedback loop. Reach for it when the point is that effects come back round. ```ts import { model } from '@diagc/core'; const m = model('word-of-mouth-growth', { name: 'Word-of-mouth growth' }); m.notation('causal-loop'); const users = m.node('users', { name: 'Active users' }); const word = m.node('word-of-mouth', { name: 'Word of mouth' }); const signups = m.node('signups', { name: 'New sign-ups' }); // Every link is '+', so nothing cancels and the loop reinforces: the renderer // badges it R. m.relate(users, word, { kind: 'influence', polarity: '+' }); m.relate(word, signups, { kind: 'influence', polarity: '+' }); m.relate(signups, users, { kind: 'influence', polarity: '+' }); export default m; ``` ## How it works - `m.notation('causal-loop')` declares the diagram. The notation has no builder of its own; the ordinary `m.node` and `m.relate` calls do the work. - A variable is `m.node(id, { name })` with no `type`. The notation draws a typeless node as bare text. - A link is `m.relate(a, b, { kind: 'influence', polarity })`. `polarity` is `'+'` when the two move the same way and `'-'` when they move opposite ways. `kind` is free-form and the loop arithmetic never reads it. - `delay: true` on a link marks an effect that arrives late. It is drawn as two hash marks and changes nothing about how a loop is classified. - Loops are found automatically from the arrows drawn. Each is badged R (reinforcing: an even number of `-` links, none included) or B (balancing: an odd number). Do not add loop labels yourself. - A loop with any unsigned link is badged `?`: unknown, not neutral. - Links running the same way between two variables count as one, and as unsigned if their polarities disagree. Two opposite links between the same pair stay two, which is what makes a two-variable loop. ## Rules - Sign every link. One unsigned link leaves its whole loop unclassified (`?`). - `polarity` other than `'+'` or `'-'` fails with `invalid-polarity`. A `delay` that is not a boolean fails with `invalid-delay`. Both are checked on every relation, whatever the notation. - Loops are badged shortest first, up to 50, each at most 20 variables long. - A typed node keeps its ordinary stencil, so leave variables typeless. --- # Deployment Where software runs: the environments, regions, networks and subnets around it, and the services, databases and load balancers inside them. Reach for it to show how a system is reached and what fails together. ```ts import { model } from '@diagc/core'; const m = model('deployment-starter', { name: 'Two-subnet VPC' }); m.notation('deployment'); const internet = m.node('internet', { name: 'Internet', type: 'deploy-internet' }); const lb = m.node('lb', { name: 'Load balancer', type: 'deploy-load-balancer' }); const app = m.node('app', { name: 'API', type: 'deploy-service', technology: 'Go' }); const db = m.node('db', { name: 'Orders DB', type: 'deploy-database', technology: 'Postgres' }); const pub = m.node('public', { name: 'Public', type: 'deploy-subnet-public' }).contains(lb); const priv = m.node('private', { name: 'Private', type: 'deploy-subnet-private' }).contains(app, db); m.node('vpc', { name: 'VPC', type: 'deploy-network' }).contains(pub, priv); m.relate(internet, lb, { kind: 'network', label: 'HTTPS 443' }); m.relate(lb, app, { kind: 'network', label: 'HTTP 8080' }); m.relate(app, db, { kind: 'network', label: 'TCP 5432' }); export default m; ``` ## How it works - `m.notation('deployment')` declares the diagram. It needs no builder call of its own: every element is an `m.node` with a `deploy-*` type. - Zones are groups, built with `contains`: an environment, a region, an availability zone, a network, a public or private subnet, a cluster, a host. They nest, outermost first. - What a box sits inside is the information. A private subnet says how a service is reached, an availability zone what it fails with. - The nodes inside the zones are a service, database, queue, storage, load balancer, gateway or firewall. `deploy-internet` is the outside: the internet, or any network you do not own. - A connection is `m.relate(a, b, { kind: 'network', label })`. The label is the port or protocol: `'HTTPS 443'`. - `technology` joins the type's subtitle under a node's name: `technology: 'Postgres'` on a `deploy-database` prints `[Database: Postgres]`. A zone's header shows only its icon and name. ## Node types `deploy-environment`, `deploy-region`, `deploy-zone`, `deploy-network`, `deploy-subnet-public`, `deploy-subnet-private`, `deploy-cluster`, `deploy-host`, `deploy-service`, `deploy-database`, `deploy-queue`, `deploy-storage`, `deploy-load-balancer`, `deploy-gateway`, `deploy-firewall`, `deploy-internet` The zones are `deploy-environment`, `deploy-region`, `deploy-zone`, `deploy-network`, `deploy-subnet-public`, `deploy-subnet-private`, `deploy-cluster` and `deploy-host` (a host is a leaf when nothing is drawn inside it); the other eight are nodes. ## Rules - Draw the zones that carry meaning, such as a private subnet or an availability zone. Do not add a box that tells the reader nothing. - Use `kind: 'network'` for every connection. A kind outside the registry draws as a plain arrow and raises the `unknown-kind` lint warning. - Pick the type from the list above. An unlisted `type` draws as a plain box and raises the `unknown-type` lint warning. - The notation adds no validation codes of its own, so nothing checks that a node sits in the right zone. Put each one in the zone it really runs in. - A zone's own `color` replaces the notation's colour for its type; leave it unset. --- # ER Entity-relationship diagram: each table is a titled box listing its columns and types, with primary and foreign keys marked. Reach for it to show how a schema is keyed. ```ts import { model } from '@diagc/core'; const m = model('library-loans', { name: 'Library loans' }); const members = m.table('members', { columns: [ { name: 'id', type: 'uuid', pk: true }, { name: 'email', type: 'text' }, { name: 'joined_on', type: 'date' }, ], }); const loans = m.table('loans', { columns: [ { name: 'id', type: 'uuid', pk: true }, { name: 'member_id', type: 'uuid', fk: true }, { name: 'due_on', type: 'date' }, ], }); m.fk(loans, 'member_id', members); export default m; ``` ## How it works - `m.table(id, { name, columns })` declares a table. `name` defaults to the id. A column is `{ name, type, pk?, fk? }`; `type` is free text and may be left out. - `m.fk(from, fromColumn, to, toColumn?, opts?)` draws a foreign key from one row to another, from the referencing column to the referenced one. - `toColumn` defaults to the target's single primary-key column. `m.fk` throws when you leave it out and the target has no primary key or several. Declare the target table, with its key, before the call, or name `toColumn`. - `m.fk` does not set `fk: true` on the source column. That flag only prints the `FK` marker; set it on the column yourself. - It needs no `m.notation` call. A table is an ordinary node, so `contains` groups tables, for example into a schema or a bounded context, like any other nodes. ## Node types `db-table` `m.table` creates the one type, so an author never writes it. ## Rules - Two columns in one table with the same name fail validation (`duplicate-column`). - A relation whose `fromColumn` or `toColumn` names no column on its table fails with `unknown-column`. - A column can carry a `layer`, and the row shows only while that layer is on. Give the `fk` relation the same layer, or its edge floats to the table's middle. A layer goes in the fifth argument, `{ layer }`, so name `toColumn` too: `m.fk(orders, 'created_by', users, 'id', { layer: 'audit' })`. The layer must be declared with `m.layer`, or validation fails with `unknown-layer`. --- # Fishbone A cause-and-effect diagram: one effect at the head of a fish, categories of cause as bones, and causes and sub-causes hanging off them. Reach for it in a postmortem, when you ask why something already happened. ```ts import { model } from '@diagc/core'; const m = model('checkout-outage', { name: 'Checkout outage' }); const fb = m.fishbone('outage', 'Checkout outage on release day'); const { people, process, code } = fb.categories('Software'); people!.cause('on-call', 'On-call engineer new to checkout'); process!.cause('no-freeze', 'No change freeze on release day'); code!.cause('migration', 'Untested schema migration').cause('no-fixture', 'No fixture with real order data'); export default m; ``` ## How it works - `m.fishbone(id, name?, opts?)` declares the diagram and its effect, the fish's head. It throws if called twice. `opts` takes `description` and `color` for the effect, and `plane` and `planeName`: `plane` puts it on a plane of its own, and `planeName` names that plane. - `fb.categories('Software')` seeds a preset of bones and returns them keyed by slug id (`people`, `infrastructure`). A key is read with `!`: `people!.cause(…)`. - The presets are `Software` (People, Process, Requirements, Code, Infrastructure, Dependencies), `6M` (Man, Machine, Method, Material, Measurement, Environment) and `4S` (Surroundings, Suppliers, Systems, Skills). - `fb.category(id, name?, opts?)` adds one bone by hand. `opts` takes `description` and `color`. - `category.cause(id, name?, opts?)` hangs a cause on a bone. `cause.cause(id, name?, opts?)` hangs a sub-cause on a cause. Each call returns the new cause, so calls chain. - The layout is the notation's own: nothing on the fish is placed by hand. Bones alternate above and below the spine in declaration order. ## Node types `fb-effect`, `fb-category`, `fb-cause` You never write these types yourself: the builder creates them. A cause and a sub-cause share one type, and where the node hangs says which it is. ## Rules - A sub-cause cannot have a cause of its own: three levels below the effect is the limit. The builder throws on the fourth. Hand-written JSON fails with `fb-too-deep`. - One effect per diagram. The builder cannot make a second one; hand-written JSON with two `fb-effect` nodes fails with `fb-many-effects`. - Fishbone nodes take no containment (`fb-contained`). The builder never adds any; only a hand-written `node.contains(…)` call or JSON can. - A cause that does not reach the effect is a warning (`fb-unattached`), not an error. It draws in a row under the fish. - A category must hang on the effect and a cause on a category or another cause. Anything else fails with `fb-misplaced`. The builder wires these correctly. --- # Git graph A branching history: one lane per branch, commits along it, and the branch-offs and merges drawn between lanes. Reach for it to explain a release train or a branching strategy. ```ts import { model } from '@diagc/core'; // Copy this file, rename the branches and the tag, and grow your own history. const m = model('dark-mode-toggle', { name: 'Dark mode toggle' }); const g = m.gitGraph(); const main = g.branch('main', { name: 'main', color: '#2f6fed' }); const feature = g.branch('dark-mode', { name: 'dark-mode', color: '#b08ad9' }); const base = main.commit(); feature.commit({ from: base }); const latest = feature.commit(); main.merge(latest, { tag: 'v3.2.0' }); export default m; ``` ## How it works - `m.gitGraph()` declares the diagram. It creates the model's first plane, so it throws if a plane already exists or if you call it twice. - `g.branch(id, { name, color })` adds a lane. Lanes are drawn top to bottom in the order you declare them. - `branch.commit('v1.0')` adds a commit after the lane's previous one. The string is its tag; with no tag the commit is a plain circle. - `branch.commit({ from: otherCommit })` starts a run branched off a commit on another lane. It throws if `from` is on the same lane. `commit({ tag, gap: n })` leaves `n` empty columns before the commit. - `branch.merge(src, { tag })` adds a commit that absorbs the commit `src` from another lane. It throws if `src` is on the same lane. - `g.stage(id, { name, from, to })` frames a span of columns across every lane. `to` defaults to `from`. Declare it after the commits it names. - Columns are worked out from the links: a commit sits one column after everything it follows, branches from or merges. Do not try to place commits. ## Node types `commit`, `branch`, `git-stage` The builder creates all three, so an author never writes them. ## Rules - A commit with more than one incoming `commit` link, or more than one incoming `branch` link, fails validation (`git-parents`). The builder cannot produce this; hand-written relations can. - A `commit` link must stay in one lane, and `branch` and `merge` links must cross lanes (`git-commit-lane`). The two throws above guard this. - A stage whose `from` or `to` is not a commit fails with `git-stage-span`. - `gap` must be a non-negative whole number (`git-gap`). The builder drops a `gap` of zero or less. --- # Plan A schedule: work as bars on a calendar, with milestones, nesting and the people who own, do and check each piece. Reach for it to show who does what and when. ```ts import { model } from '@diagc/core'; // Copy this file, rename the zones and the people, and move the dates. const m = model('launch-plan', { name: 'Launch plan' }); const plan = m.plan(); const alice = plan.person('alice', 'Alice Ng', { color: '#2f6fed' }); const bob = plan.person('bob', 'Bob Lee', { color: '#b08ad9' }); const chen = plan.person('chen', 'Chen Wu', { color: '#3a9d5d' }); const support = plan.team('support', 'Support team', { color: '#607d8b' }); const discovery = plan.zone('discovery', { name: 'Discovery', start: '2026-01-05', end: '2026-01-23', color: '#b08ad9' }); discovery.owner(alice).executor(bob); const build = plan.zone('build', { name: 'Build', start: '2026-01-26', end: '2026-03-06', color: '#2f6fed' }); build.owner(alice).executor(bob).checker(chen); build.zone('api', { name: 'API', start: '2026-01-26', end: '2026-02-13' }); build.zone('ui', { name: 'UI', start: '2026-02-09', end: '2026-03-06' }); build.event('code-freeze', { name: 'Code freeze', at: '2026-03-02' }); build.comment('UI started a week late; the freeze holds', { by: 'Alice Ng', at: '2026-02-16' }); build.link('Tracker', 'https://example.com/board/launch'); plan.zone('rollout', { name: 'Rollout', start: '2026-03-09', end: '2026-03-20', color: '#3a9d5d' }).owner(chen).executor(support); plan.event('launch', { name: 'Launch', at: '2026-03-23' }); export default m; ``` ## How it works - `m.plan(id?, opts?)` declares the schedule on a plane of its own (`id` defaults to `plan`; `opts` takes `name`). It throws if called twice. A model that already has other planes can add one, and `zone.contains(...)` then schedules its nodes. - `plan.zone(id, { name, start, end })` is a bar. Dates are `YYYY-MM-DD` and `end` is inclusive. `color` and the other node options go in the same object. - `zone.zone(id, { … })` nests a zone inside another; the nested dates must lie inside the parent's. - `plan.event(id, { name, at })` and `zone.event(id, { name, at })` are single dates, drawn as diamonds. A nested event must fall inside its zone. - `plan.person(id, name?, opts?)` and `plan.team(id, name?, opts?)` are the actors. `zone.owner(handle)`, `zone.executor(handle)` and `zone.checker(handle)` give one a role on a zone, and chain. A role shows as a chip on the bar, not as an arrow. - `zone.comment(text, opts?)` and `zone.link(label, url)` chain on a zone. `opts` takes `by`, `at` and `id`. - The bars are placed from their dates: x is always the date, and rows inside a zone are automatic. Do not place them. ## Node types `plan-zone`, `plan-event` A person and a team use the plain `person` and `team` types, so they look the same on any diagram. ## Rules - A zone needs both `start` and `end`, and an event `at`; a missing one fails with `plan-missing`. A date that is not a real `YYYY-MM-DD` day fails with `plan-date`. The builder does not check dates: the types make them required, and validation reports the rest. - A zone whose `end` is before its `start` fails with `plan-span`. - A zone or event contained in a zone, on the plan plane, that lies outside that zone's dates fails with `plan-nested`. - The three role methods always target the zone. A hand-written relation of kind `owns`, `executes` or `checks` whose target is not a `plan-zone` fails with `plan-role-target`; those are the only kinds drawn as chips. --- # Second-order thinking A consequence tree: a decision, what follows from it, and what follows from that, banded by how many steps each consequence sits from the decision. Reach for it to look past the first effect of a choice. ```ts import { model } from '@diagc/core'; const m = model('require-2fa', { name: 'Requiring two-factor authentication' }); const so = m.secondOrder(); const decision = so.decision('mandate-2fa', 'Require 2FA for every login'); const fewerBreaches = decision.then('fewer-breaches', 'Account takeovers drop', { valence: '+' }); const lockouts = decision.then('lockouts', 'Support is flooded with lockout tickets', { valence: '-' }); fewerBreaches.then('trust', 'Customers trust the product more', { valence: '+' }); lockouts.then('contractor', 'Support hires a contractor to keep up', { valence: '-' }); export default m; ``` ## How it works - `m.secondOrder(opts?)` declares the diagram. It throws if called twice. `opts` takes `plane` and `name`: `plane` puts it on a plane of its own, and `name` names that plane. - `so.decision(id, name?, opts?)` is the root. `opts` takes `description` and `color`. Several decisions can share one set of bands. - `ref.then(id, name?, opts?)` adds what follows from `ref`, and the arrow to it. `opts` is `{ valence, label, description, color }`. It returns the new consequence, so calls chain. - `valence` is `'+'` good, `'-'` bad or `'0'` neutral. It defaults to `'0'` and picks the node type. `label` labels the arrow that leads to the consequence. - `ref.leadsTo(other, opts?)` joins two branches without a new node: `ref` also leads to a consequence declared elsewhere. `opts` takes `label`. It returns `ref`. - The bands (first order, second order, and so on) are worked out from the arrows: a node's order is its longest path from a decision. Moving a box by hand does not change its band. ## Node types `so-decision`, `so-consequence-positive`, `so-consequence-negative`, `so-consequence-neutral` The builder picks the consequence type from `valence`, so an author never writes these types. ## Rules - A cycle among the consequences is an error (`so-cycle`). A loop belongs in a causal-loop diagram. The builder does not stop one: `leadsTo` can close a cycle, and compiling then fails. - Decisions and consequences take no containment (`so-contained`). The builder never adds any; only a hand-written `node.contains(…)` call or JSON can. - A consequence that no decision leads to fails with `so-unreachable`. A diagram with consequences and no decision fails with `so-no-decision`. The builder cannot produce either: `then` always has a source and `decision` is the way in. --- # Threat model A STRIDE data-flow diagram: the entities, processes and data stores a system moves data between, the flows between them, and the trust boundaries those flows cross. Reach for it to review what an attacker could still do, and to keep the findings with the picture. ```ts import { model } from '@diagc/core'; const m = model('password-reset', { name: 'Password reset flow' }); const tm = m.threatModel(); const customer = tm.entity('customer', 'Customer'); const auth = tm.process('auth', 'Auth service'); tm.boundary('internet-facing', 'DMZ').contains(auth); tm.flow(customer, auth, 'HTTPS: reset request').threat({ category: 'I', title: 'Reset token leaks to third parties via the Referer header', severity: 'medium', }); export default m; ``` ## How it works - `m.threatModel(opts?)` declares the diagram. It throws if called twice. `opts` takes `plane` and `name`: `plane` puts it on a plane of its own, and `name` names that plane. Without a `plane` the whole model is the threat model and `name` is ignored. - `tm.entity(id, name?)` is something outside the system, `tm.process(id, name?)` something the system does with the data, `tm.store(id, name?)` where the data rests. `tm.boundary(id, name?)` is a trust boundary. Each returns an ordinary node handle. - A boundary is a group: `boundary.contains(a, b)`. Which boundaries a flow crosses is worked out from that containment; you never declare a crossing. - `tm.flow(from, to, labelOrOpts?)` is one data flow, from the source of the data to its destination. A string is its label. A request and its response are two flows. - `.threat({ category, title, severity?, status?, mitigation?, description?, id? })` records a finding on an element or on a flow, and chains. `category` is one of `S` Spoofing, `T` Tampering, `R` Repudiation, `I` Information disclosure, `D` Denial of service, `E` Elevation of privilege. `severity` is `low`, `medium`, `high` or `critical`; unset it is unrated, shown as `—`. `status` is `open`, `mitigated`, `accepted` or `not-applicable`; unset it means `open`. - The published page lists every threat in a table under the picture: element, the boundaries it crosses, category, title, severity, status and mitigation. ## Node types `tm-entity`, `tm-process`, `tm-store`, `tm-boundary` You never write these types yourself: the four `tm.` helpers set them, and `tm-boundary` is the one you nest the other three in. ## Rules - Record the threats the person you are drawing it for has named. Do not invent threats to fill the table; a flow or element with none is fine. - A flow joins two elements, never a boundary. `tm.flow` does not stop you, but a `data-flow` relation that starts or ends on a `tm-boundary` fails validation with `tm-flow-boundary`. - Use `data-flow` (what `tm.flow` writes). A relation of another kind still counts as a crossing and still carries threats, but it is not checked for `tm-flow-boundary`. - A threat needs a `title` (`threat-title`) and a `category` from `S T R I D E` (`threat-category`). A `severity` or `status` outside the lists above fails with `threat-severity` or `threat-status`. The types allow only the listed values; JSON can still write others. - A threat `id` defaults to `t1`, `t2`, … on its own element. The builder throws on a repeated `id` on one element; hand-written JSON fails with `threat-id`.