Annotate an existing codebase
You will annotate a four-file service you did not write, get validate to a
state you can defend, commit the generated artifacts, and prove the CI gate
catches a refactor that moves annotated code.
The order matters more than the volume. Annotating file by file produces a model full of near-duplicate ids that nothing joins on. This guide does vocabulary first, then seams, then coverage.
Before you start
Section titled “Before you start”- GuardLink installed. See Install GuardLink. Everything below is 2.0.0.
- git, to see the drift check do something.
- A repository you can commit to. The example builds one.
Build something to annotate
Section titled “Build something to annotate”If you have a real repository, use it and skip to Initialize, since the shape of the work is the same. Otherwise:
-
Create the project.
Terminal window mkdir legacy-apicd legacy-apigit initnpm init -ynpm install --save-dev guardlink -
Write four files: an HTTP layer, two modules with security-relevant behaviour, and a database helper.
src/routes.js import { findUserByEmail } from './users.js'import { redeemRefreshToken } from './tokens.js'export async function handleGetUser(req, res) {res.json(await findUserByEmail(req.query.email))}export async function handleRefresh(req, res) {const userId = redeemRefreshToken(req.body.token)res.json({ userId })}src/users.js import { query } from './db.js'export async function findUserByEmail(email) {return query(`SELECT id, email, phone FROM users WHERE email = '${email}'`)}src/tokens.js import crypto from 'node:crypto'const REFRESH_TOKENS = new Map()export function issueRefreshToken(userId) {const token = crypto.randomBytes(32).toString('hex')REFRESH_TOKENS.set(token, { userId })return token}export function redeemRefreshToken(token) {const record = REFRESH_TOKENS.get(token)if (!record) return nullREFRESH_TOKENS.delete(token)return record.userId}src/db.js export async function query(sql) {return { sql, rows: [] }}
Initialize
Section titled “Initialize”npx guardlink init . --agent claudeFor a repository with other contributors, keep the default external mode.
Annotations land in .guardlink/annotations/ and no source file is touched, so
the first pull request is additive and reviewable on its own. Inline mode edits
every file you annotate, a defensible choice but not one to make in the same
change as the annotations themselves.
See the size of the job
Section titled “See the size of the job”npx guardlink status .GuardLink Status: legacy-api────────────────────────────────────────Files scanned: 5 Files annotated: 0 Files unannotated: 4Annotations: 0────────────────────────────────────────Five scanned, four unannotated: the fifth is .guardlink/definitions.js, which
init wrote.
npx guardlink unannotated .⚠ 4 source file(s) with no annotations: src/db.js src/routes.js src/tokens.js src/users.jsWrite the vocabulary first
Section titled “Write the vocabulary first”Every @asset, @threat and @control goes in .guardlink/definitions.js,
each with a #id, and nothing else declares one. That split, declarations in one
place and claims referencing them, is what makes the model checkable at all. See
the annotation language. Doing this before the
relationships is what stops you from ending up with #user, #users and
#user-record describing the same thing.
Three questions, in this order:
- What is worth protecting? Those are the assets. Aim at the granularity
you would name in an incident review, so
App.Usersrather than every function. - What could go wrong to each? Those are the threats. Give each one a CWE if there is an obvious one; it is what a downstream scanner joins on.
- What already stands in the way? Those are the controls. Only ones that exist. A control you plan to build is not a control.
// @asset App.API (#api) -- "HTTP surface"// @asset App.Users (#users) -- "User records: id, email, phone"// @asset App.Tokens (#tokens) -- "Refresh tokens issued at login"
// @threat SQL_Injection (#sqli) [critical] cwe:CWE-89 -- "Unsanitized input reaches a SQL query"// @threat Token_Replay (#replay) [high] cwe:CWE-294 -- "A captured refresh token is redeemed more than once"
// @control Parameterized_Queries (#prepared-stmts) -- "Queries bind parameters instead of interpolating"// @control Single_Use_Tokens (#single-use) -- "A refresh token is deleted on redemption"Severity on the @threat is inherited by any @exposes that omits its own, so
setting it once here covers most cases.
Annotate the seams
Section titled “Annotate the seams”Not every file. The places where trust changes: input arrives, data is read or written, a credential is handled, a boundary is crossed. Four verbs carry this guide; the GAL reference has the other seventeen.
-
Start with the routes. Route annotations are the highest-value ones in the file, because a
@flowswhose mechanism is writtenMETHOD./pathis what tells a downstream scanner where to send traffic. A blank line separates one@sourceblock from the next..guardlink/annotations/src/routes.js.gal @source file:src/routes.js line:4 symbol:handleGetUser@flows #api -> #users via GET./api/users@source file:src/routes.js line:8 symbol:handleRefresh@flows #api -> #tokens via POST./api/refresh -
Then the risk you can see.
findUserByEmailinterpolates a caller-supplied string into SQL and returns two pieces of personal data..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 is interpolated into the SQL string" -
Then the risk that is already handled.
redeemRefreshTokendeletes the record before returning, so replay is answered. Write both halves: the@exposesrecords that this code path is where replay would happen, and the@mitigatesrecords what answers it..guardlink/annotations/src/tokens.js.gal @source file:src/tokens.js line:12 symbol:redeemRefreshToken@exposes #tokens to #replay@mitigates #tokens against #replay using #single-use -- "The record is deleted before the user id is returned"Recording only the mitigation loses the reason it exists, and the next person to simplify the function has nothing telling them what they are removing.
-
Leave
src/db.jsalone. It is a passthrough. An annotation on it would restate whatusers.jsalready says, and an asset that means nothing is worse than an uncovered file.
Validate, and read what it says
Section titled “Validate, and read what it says”npx guardlink validate .⚠ 1 unmitigated exposure(s): #users → #sqli [critical] (src/users.js:3)
Validation passed with 1 unmitigated exposure(s).Exit 0. That unmitigated exposure is the correct state of this repository: the
SQL injection is real and nothing mitigates it. validate fails on annotations
that are broken, not on risks that are open.
If it reports diagnostics
Section titled “If it reports diagnostics”All four kinds, from one deliberately broken sidecar:
@source file:src/db.js line:1 symbol:query@flow #api -> #users via GET./api/users@exposes@exposes #users to #nonexistent@feature still claims to describe the model⚠ .guardlink/annotations/src/db.js.gal:2: Unknown annotation verb @flow — did you mean @flows? Unrecognised verbs are discarded silently, so this line contributes nothing to the model. → @flow #api -> #users via GET./api/users✗ .guardlink/annotations/src/db.js.gal:3: Malformed @exposes annotation: could not parse arguments (looks structural — found no arguments at all) → @exposes⚠ src/db.js:1: Dangling reference: #nonexistent is never defined
Lines that look like prose, not annotations (1) — these do not fail validation: .guardlink/annotations/src/db.js.gal:5: @feature still claims to describe the model Each begins with a GuardLink verb but carries no #reference, no `--` delimiter, and none of that verb's grammar keywords, so it was read as prose and not parsed. If one IS an annotation, it is missing its arguments. If it is documentation, wrap it in @shield:begin / @shield:end.
1 error(s), 3 warning(s)Exit 1, because of the one error.
| Diagnostic | Level | What to do |
|---|---|---|
unknown-verb |
warning | Fix the typo. The line contributes nothing until you do. |
malformed-annotation |
error | Real syntax error. It has structure, a #ref, a spaced --, or one of that verb’s own keywords, so it was meant to be an annotation. |
prose-like |
warning | Starts with a verb name but has no structure, so it was read as a sentence. Wrap deliberate examples in @shield:begin / @shield:end. |
dangling-ref |
warning | The #id has no definition. Add it, or fix the reference. |
The unknown-verb warning is new in 2.0.0. Before it, @flow produced no
annotation and no diagnostic, so a typo was indistinguishable from a line you
never wrote.
You can turn the warnings off per code in .guardlink/config.json:
{ "diagnostics": { "unknown-verb": false } }Only warnings are suppressible. Listing an error-level code is accepted and
ignored. With malformed-annotation disabled, the error still prints and
validate still exits 1.
Fix the anchors before you commit
Section titled “Fix the anchors before you commit”npx guardlink ci .Unmitigated exposures: 1 (critical 1)Anchor drift: 1 (moved 1) of 4 anchor(s)
⚠ 1 unmitigated exposure(s): #users → #sqli [critical] (src/users.js:3)
⚠ 1 drifted @source block(s): [moved] `.guardlink/annotations/src/tokens.js.gal` anchors to `redeemRefreshToken` at `src/tokens.js:12`, but `redeemRefreshToken` is now at line 11.A line number counted by hand is usually off by one. moved means the symbol
still exists somewhere else in the file, which guardlink can repair:
npx guardlink reanchor . --apply1 drifted @source block(s):
[moved] `.guardlink/annotations/src/tokens.js.gal` anchors to `redeemRefreshToken` at `src/tokens.js:12`, but `redeemRefreshToken` is now at line 11.
✓ Re-anchored 1 file(s): .guardlink/annotations/src/tokens.js.galNow status reflects the whole job:
Files scanned: 5 Files annotated: 4 Files unannotated: 1Annotations: 13────────────────────────────────────────Assets: 3Threats: 2Controls: 2Mitigations: 1Exposures: 2Commit the artifacts
Section titled “Commit the artifacts”npx guardlink artifacts .git add -Agit commit -m "add guardlink threat model"artifacts writes .guardlink/model.json and .guardlink/graph/, which is three
Mermaid diagrams and a manifest. They are committed on purpose: a fresh clone has
the model without running anything, and a reviewer sees model changes as a diff.
init already wrote the .gitattributes that marks them generated and tells you
to regenerate rather than hand-merge on a conflict.
threat-graph.mmd is the one worth opening first. It is plain Mermaid, so it
renders anywhere Mermaid does, including here. This is what the annotations above
produce, pasted unedited:
%%{init: {"flowchart": {"nodeSpacing": 55, "rankSpacing": 150, "curve": "monotoneX", "htmlLabels": false, "padding": 24}}}%%
graph LR
tokens["🔷 App.Tokens"]
users["🔷 App.Users [PII]"]
replay["🟠 Token_Replay (cwe:CWE-294)"]:::threat
sqli["🔴 SQL_Injection (cwe:CWE-89)"]:::threat
single_use["🛡️ Single_Use_Tokens"]:::control
tokens -. exposes .-> replay
users -. exposes .-> sqli
single_use -- mitigates --> replay
single_use -. protects .-> tokens
classDef threat fill:#3a1010,stroke:#ea1d1d,color:#f0f0f0,stroke-width:1.3px
classDef control fill:#102a24,stroke:#33d49d,color:#f0f0f0,stroke-width:1.3px
Two things are worth reading off it. App.Users carries a [PII] tag, which
came from the @handles pii line rather than from anything you told the diagram.
And SQL_Injection has nothing pointing at it, while Token_Replay has
Single_Use_Tokens pointing at it. That gap is the unmitigated exposure, drawn.
The %% header above the diagram is trimmed here for length. In the real file it
carries the annotation_hash the diagram was generated from, so
guardlink validate . --artifacts can tell you a diagram is stale rather than
letting you trust a picture of a model that has moved on.
Prove the gate does something
Section titled “Prove the gate does something”A threat model that has only ever been run against the state it was written for has not been tested. Move the annotated code and see whether anything notices.
-
Insert a line above
findUserByEmail.src/users.js import { query } from './db.js'const COLUMNS = 'id, email, phone'export async function findUserByEmail(email) {return query(`SELECT ${COLUMNS} FROM users WHERE email = '${email}'`)} -
Run the gate.
Terminal window npx guardlink ci .Unmitigated exposures: 1 (critical 1)Anchor drift: 1 (moved 1) of 4 anchor(s)⚠ 1 unmitigated exposure(s):#users → #sqli [critical] (src/users.js:3)⚠ 1 drifted @source block(s):[moved] `.guardlink/annotations/src/users.js.gal` anchors to `findUserByEmail` at `src/users.js:3`, but `findUserByEmail` is now at line 5.Advisory — nothing here failed the build. Run with --strict to gate on it. -
Confirm what does not notice, because this is the part worth internalising.
Terminal window npx guardlink diff HEADParsing current threat model...Parsing threat model at HEAD...No threat model changes detected.Terminal window npx guardlink validate . --artifacts✓ Artifacts are current.The claims did not change, so the model did not change, so the hash did not move and the committed diagrams are still accurate. Only
ciandreanchorlook at anchors. Drift is a defect in where the annotation points, not in what it says, and the two checks are separate because the fixes are. -
Repair it.
Terminal window npx guardlink reanchor . --apply✓ Re-anchored 1 file(s): .guardlink/annotations/src/users.js.gal
And prove the artifact check does something
Section titled “And prove the artifact check does something”Now change a claim rather than a line. Add the mitigation you would write once the query is fixed:
@mitigates #users against #sqli using #prepared-stmts -- "Rewritten to bind the email parameter"npx guardlink validate . --artifacts✓ All annotations valid, no unmitigated exposures.
⚠ 3 artifact issue(s): .guardlink/graph/threat-graph.mmd — STALE built from: sha256-v2:e9fef760833ce95e93bc672951ff0f24694b243af8db9c2337173e67cd3e4929 model is: sha256-v2:17a41e0fbbb02cb53129f74437e92fb4b13914c2f8c117942e2781964e2c7afc .guardlink/graph/dataflow.mmd — STALE built from: sha256-v2:e9fef760833ce95e93bc672951ff0f24694b243af8db9c2337173e67cd3e4929 model is: sha256-v2:17a41e0fbbb02cb53129f74437e92fb4b13914c2f8c117942e2781964e2c7afc .guardlink/graph/attack-surface.mmd — STALE built from: sha256-v2:e9fef760833ce95e93bc672951ff0f24694b243af8db9c2337173e67cd3e4929 model is: sha256-v2:17a41e0fbbb02cb53129f74437e92fb4b13914c2f8c117942e2781964e2c7afc
Regenerate with: guardlink artifacts . Never hand-edit an artifact to silence this — the hash describes the annotations.Exit 1. And diff now has something to report:
Threat Model Diff: 1 change(s) +1 added -0 removed ~0 modified
✓ 1 exposure(s) resolved — risk decreased
── Resolved Exposures ── ✓ #users → #sqli (src/users.js:3)
── Mitigations ── + #users ← #prepared-stmts against #sqliRun guardlink artifacts . to bring the diagrams back in line.
Wire it into CI
Section titled “Wire it into CI”Three commands, in increasing strictness. Start at the top and move down as the repository earns it.
npx guardlink validate . --artifacts # exit 1 on syntax errors, dangling refs, stale artifactsnpx guardlink ci . # always exit 0 — reports exposures and driftnpx guardlink ci . --strict # exit 1 if either is non-zeroci is advisory by default for a reason: a repository annotated last week
has unmitigated exposures by construction, and a gate that fails the
build on the day the annotations land is a gate someone deletes the same week.
--strict is what you turn on after reaching zero, to stay there.
guardlink diff <base> --fail-on-new is the middle ground, exiting 1 only
when a change introduces an unmitigated exposure, so existing debt does not
block anything. --markdown renders the same diff for a pull-request comment.
Hand the bulk to a coding agent
Section titled “Hand the bulk to a coding agent”For a large repository, writing every sidecar by hand is the wrong use of your
time. guardlink annotate builds a prompt carrying the annotation grammar, your
definitions, and the project’s rules, and hands it to an agent:
npx guardlink annotate "annotate the database layer" . --stdout--stdout prints the prompt for piping. --claude-code, --codex and
--gemini launch those CLIs in the foreground; --cursor, --windsurf and
--clipboard put the prompt on the clipboard.
Review what it writes the way you would review any other change. An agent
proposes claims; validate checks they are internally consistent; only you know
whether they are true.
The MCP server is the better path for an agent that is already in your editor. It can read the model and write sidecars through a validated API instead of generating text. See Wire the MCP server into a coding agent.
What to change next
Section titled “What to change next”- Annotate the boundaries.
@boundary #api and #users (#edge)records where trust changes, and it is what makes the data-flow diagram readable. - Tag features.
@feature "Login"letsguardlink report . --feature Loginnarrow the report to one slice. - Record ownership.
@owns platform-team for #usersputs a name on who reviews changes to an asset. - Read the rest of the grammar. The GAL reference
lists all twenty verbs with an example each, generated from
guardlink gal. - Export for a scanner.
guardlink sarif .turns the open exposures into findings other tools read. See Feeding findings into cxg.
Related
Section titled “Related”- Why annotations live in code covers the trade this page made for you, including what guardlink does not check.
- The threat model as an artifact covers what you committed, and what identifies a version of it.
guardlink ciandguardlink validatedocument every flag on the two commands this guide gates on.

