Write your first annotation
By the end of this page one command reads a claim you wrote and reports it against a real line of code:
npx guardlink validate .⚠ 1 unmitigated exposure(s): #users → #sqli [critical] (src/users.js:3)
Validation passed with 1 unmitigated exposure(s).Three pieces produce that line: a definition naming what is at stake, an
annotation claiming a risk against it, and an anchor tying the claim to
src/users.js:3. This page writes all three, then closes the risk and gates a
build on it.
It continues the project from Install GuardLink.
Define the vocabulary
Section titled “Define the vocabulary”Assets, threats, and controls are declared once, in .guardlink/definitions.js,
each with a #id. Everything else references those ids. Append to the file:
// @asset App.Users (#users) -- "User records: id, email, phone"
// @threat SQL_Injection (#sqli) [critical] cwe:CWE-89 -- "Unsanitized input reaches a SQL query"
// @control Parameterized_Queries (#prepared-stmts) -- "Queries bind parameters instead of interpolating"Declaring ids once is what makes the model checkable rather than a pile of
strings: a later claim referencing #sqli either resolves to that threat or
validate fails on it.
| Part | Shape | Notes |
|---|---|---|
| Name | App.Users |
Dotted path, your choice |
| Id | (#users) |
What every other annotation references |
| Severity | [critical] |
One of critical, high, medium, low, or P0 to P3 |
| External reference | cwe:CWE-89 |
Any scheme:value, so owasp:A03:2021 and attack:T1190 work the same way |
| Description | -- "…" |
Always -- "quoted text", never a colon |
Write the annotation
Section titled “Write the annotation”Create the sidecar. The path mirrors src/users.js with .gal appended:
@source file:src/users.js line:3 symbol:findUserByEmail@handles pii on #users -- "Returns email and phone"@exposes #users to #sqli [critical] -- "email is interpolated into the SQL string".gal files hold raw annotation lines, with no // prefix. The @source header
anchors every line below it to a real code location: findUserByEmail is on
line 3 of src/users.js. symbol: is optional, and it is what lets guardlink
find the block again after a refactor moves the function.
Check it:
npx guardlink validate .⚠ 1 unmitigated exposure(s): #users → #sqli [critical] (src/users.js:3)
Validation passed with 1 unmitigated exposure(s).Exit code 0. An unmitigated exposure is not an error. Recording a risk before
its control exists is the intended order of work. What validate fails on is a
malformed annotation or a #id that resolves to nothing.
The location it reports is src/users.js:3, not the sidecar. Every consumer of
the model sees the code position, which is why the @source line has to be
right.
Close the loop
Section titled “Close the loop”Fix the query, then say so in the model.
-
Bind the parameter instead of interpolating it.
src/users.js import { query } from './db.js'export async function findUserByEmail(email) {return query('SELECT id, email, phone FROM users WHERE email = $1', [email])} -
Record the control against the threat. Replace the sidecar with:
.guardlink/annotations/src/users.js.gal @source file:src/users.js line:3 symbol:findUserByEmail@handles pii on #users -- "Returns email and phone"@exposes #users to #sqli [critical] -- "email reaches the SQL string"@mitigates #users against #sqli using #prepared-stmts -- "Bound parameter, not interpolation"The
@exposesstays. It is the record that this code path handles untrusted input at all, and deleting it would delete the reason the control exists. -
Validate again.
Terminal window npx guardlink validate .✓ All annotations valid, no unmitigated exposures. -
Run the CI gate.
Terminal window npx guardlink ci . --strictUnmitigated exposures: 0Anchor drift: 0 of 1 anchor(s)✓ No unmitigated exposures, no anchor drift.Exit code
0. Without--strict,cireports the same findings and always exits0, because it is advisory by default so a first-run repository does not fail its build on the day the annotations land.0 of 1 anchor(s)is the line worth reading. Zero drift out of zero anchors is a different statement from zero drift out of one, and only the second means anything was checked.
Take the artifacts
Section titled “Take the artifacts”npx guardlink report .✓ Wrote threat model report to threat-model.mdthreat-model.md is a full report covering scope, architecture, a Mermaid threat
diagram, data inventory, active mitigations, and data classification, built from the
six annotations you wrote. Sections with nothing behind them say so rather than
being omitted, so the gaps are visible. Its header records the version that
produced it:
# Threat Model Report — guardlink-quickstart
> Generated: 2026-09-09T19:52:35.759Z> Files scanned: 3 | Annotations: 6> GuardLink version: 2.0.0For the machine-readable form, and for diagrams you commit:
npx guardlink artifacts .Wrote 6 artifact(s): .guardlink/graph/threat-graph.mmd .guardlink/graph/dataflow.mmd .guardlink/graph/attack-surface.mmd .guardlink/model.json .guardlink/graph/MANIFEST.json .guardlink/graph/README.md
annotation_hash: sha256-v2:8bed94a125e3e3ed533cd5acd7936a1e985e7e3c886fbf4a0f06c30994308278generated_at: 2026-09-09T19:52:40.266Zgit_sha: not a git checkout(the last two are reported here, not written into the files — they would otherwise churn every commit; the files are tracked.)Every .mmd carries that hash in a %% header. Check with: guardlink validate . --artifactsYour annotation_hash will match this one if your annotations match; it is a
hash of the model, not of the run. generated_at and git_sha differ every
time, which is exactly why they are printed rather than written into the files.
.guardlink/model.json is the whole model as JSON, canonically ordered so the
diff is readable:
{ "version": "1.2.0", "project": "guardlink-quickstart", "source_files": 3, "annotations_parsed": 6, "annotated_files": [ ".guardlink/definitions.js", "src/users.js" ], "unannotated_files": [ "src/db.js" ],Truncated after unannotated_files. The file continues with one array per verb
and ends with coverage and external_refs. The @exposes line you wrote is
one entry in the exposures array:
{ "asset": "#users", "threat": "#sqli", "severity": "critical", "external_refs": [], "description": "email reaches the SQL string", "location": { "file": "src/users.js", "line": 3, "parent_symbol": "findUserByEmail", "origin_file": ".guardlink/annotations/src/users.js.gal", "origin_line": 3 }}That is the whole mapping: a verb becomes an array, and each annotation becomes
one object in it. file and line are the code position, origin_file and
origin_line are the sidecar the claim was written in, and parent_symbol is
what lets reanchor find the block again after a refactor moves the function.
External mode records both positions so a consumer can point a developer at the
annotation to edit while pointing a scanner at the code.
The threat model as an artifact
reads the rest of it.
If validate reports something you did not expect
Section titled “If validate reports something you did not expect”In order of likelihood.
- Nothing is reported at all, and
statussays 0 annotations. Check the sidecar path..guardlink/annotations/src/users.js.galmirrors the source path exactly, including the source file’s own extension. Unknown annotation verb @flow, did you mean @flows?The verb is not in the grammar, so the line contributes nothing. guardlink 2.0.0 warns when a bad verb is close to a real one; earlier versions dropped it in silence.npx guardlink galprints every verb.Dangling reference: #x is never defined. The#idhas no@asset,@threat, or@controlbehind it in.guardlink/definitions.js. Definitions live there in both annotation modes.Malformed @exposes annotation: could not parse arguments, andvalidateexits1. The line has structure, a#ref, a--delimiter, or one of that verb’s own keywords, so it was meant to be an annotation and is treated as broken rather than as prose. Descriptions use-- "quoted text", never a colon.is not at the conventional path. The.galfile is somewhere else. It is still parsed, since this is a warning rather than a refusal, but nothing will look for it there.
Do not write the next hundred by hand
Section titled “Do not write the next hundred by hand”Every command on these two pages reads files from disk and writes files to disk, with no account, no API key, and no server. That is why they make up the onboarding path.
For a repository that already exists, writing every sidecar by hand is the wrong
use of your time. guardlink annotate builds a prompt carrying the grammar, your
definitions, and your project’s rules, then hands it to a coding agent you
already have:
npx guardlink annotate "annotate the database layer" . --stdout--stdout prints that prompt and exits, so you can read exactly what would be
sent before anything is. --claude-code, --codex and --gemini launch those
CLIs in the foreground; --cursor, --windsurf and --clipboard put the prompt
on the clipboard. guardlink itself makes no network call in any of those modes,
and the agent you point it at does.
- Annotate an existing codebase runs the same loop against code you did not write, starting from coverage gaps, and ends with a copyable GitHub Actions workflow.
- Wire the MCP server into a coding agent gives an agent 24 tools to read and extend the model instead of guessing.
- Why annotations live in code covers what this buys over a threat model in a document, and what it costs.
- CLI reference has every command, argument, and
option, generated from the installed package.
npx guardlink galprints the annotation grammar.

