Skip to content

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.

  • 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.

If you have a real repository, use it and skip to Initialize, since the shape of the work is the same. Otherwise:

  1. Create the project.

    Terminal window
    mkdir legacy-api
    cd legacy-api
    git init
    npm init -y
    npm install --save-dev guardlink
  2. 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 null
    REFRESH_TOKENS.delete(token)
    return record.userId
    }
    src/db.js
    export async function query(sql) {
    return { sql, rows: [] }
    }
Terminal window
npx guardlink init . --agent claude

For 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.

Terminal window
npx guardlink status .
GuardLink Status: legacy-api
────────────────────────────────────────
Files scanned: 5
Files annotated: 0
Files unannotated: 4
Annotations: 0
────────────────────────────────────────

Five scanned, four unannotated: the fifth is .guardlink/definitions.js, which init wrote.

Terminal window
npx guardlink unannotated .
⚠ 4 source file(s) with no annotations:
src/db.js
src/routes.js
src/tokens.js
src/users.js

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:

  1. What is worth protecting? Those are the assets. Aim at the granularity you would name in an incident review, so App.Users rather than every function.
  2. 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.
  3. What already stands in the way? Those are the controls. Only ones that exist. A control you plan to build is not a control.
.guardlink/definitions.js
// @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.

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.

  1. Start with the routes. Route annotations are the highest-value ones in the file, because a @flows whose mechanism is written METHOD./path is what tells a downstream scanner where to send traffic. A blank line separates one @source block 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
  2. Then the risk you can see. findUserByEmail interpolates 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"
  3. Then the risk that is already handled. redeemRefreshToken deletes the record before returning, so replay is answered. Write both halves: the @exposes records that this code path is where replay would happen, and the @mitigates records 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.

  4. Leave src/db.js alone. It is a passthrough. An annotation on it would restate what users.js already says, and an asset that means nothing is worse than an uncovered file.

Terminal window
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.

All four kinds, from one deliberately broken sidecar:

.guardlink/annotations/src/db.js.gal
@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.

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/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:

Terminal window
npx guardlink reanchor . --apply
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.
✓ Re-anchored 1 file(s): .guardlink/annotations/src/tokens.js.gal

Now status reflects the whole job:

Files scanned: 5
Files annotated: 4
Files unannotated: 1
Annotations: 13
────────────────────────────────────────
Assets: 3
Threats: 2
Controls: 2
Mitigations: 1
Exposures: 2
Terminal window
npx guardlink artifacts .
git add -A
git 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.

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.

  1. 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}'`)
    }
  2. 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.
  3. Confirm what does not notice, because this is the part worth internalising.

    Terminal window
    npx guardlink diff HEAD
    Parsing 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 ci and reanchor look 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.

  4. 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:

.guardlink/annotations/src/users.js.gal
@mitigates #users against #sqli using #prepared-stmts -- "Rewritten to bind the email parameter"
Terminal window
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 #sqli

Run guardlink artifacts . to bring the diagrams back in line.

Three commands, in increasing strictness. Start at the top and move down as the repository earns it.

Terminal window
npx guardlink validate . --artifacts # exit 1 on syntax errors, dangling refs, stale artifacts
npx guardlink ci . # always exit 0 — reports exposures and drift
npx guardlink ci . --strict # exit 1 if either is non-zero

ci 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.

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:

Terminal window
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.

  • 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" lets guardlink report . --feature Login narrow the report to one slice.
  • Record ownership. @owns platform-team for #users puts 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.