Skip to content

Why annotations live in code

A threat model is a set of claims about code: this component holds personal data, that input reaches a query, this control is what stands between them. guardlink keeps those claims in the repository, next to the code they describe, and parses them into a model.

This page explains what that changes and what it does not. It is not an argument that a document-based threat model is wrong. For a system you are designing rather than one you are maintaining, a document is usually better, and the last section says when.

One line, one claim, written where the claim is true:

@exposes #users to #sqli [critical] -- "email is interpolated into the SQL string"

@exposes is the verb, #users and #sqli are references to definitions declared once in .guardlink/definitions.js, [critical] is severity, and everything after -- is prose. The verbs cover definitions (@asset, @threat, @control, @actor), relationships (@exposes, @mitigates, @flows, @boundary, @transfers, @confirmed, @accepts, @entitles), and lifecycle facts (@handles, @owns, @audit, @validates, @assumes). npx guardlink gal prints the whole grammar, with an example per verb.

The claims are not free text. guardlink validate refuses a malformed line and reports a #id that resolves to nothing, so a threat model in this form cannot quietly reference a component that no longer exists.

A threat model in a wiki page, a Confluence space, or a THREAT-MODEL.md is not wrong. It is unowned by the change that invalidates it.

Concretely: someone rewrites findUserByEmail to take a filter object instead of a string. Nothing in that pull request mentions the threat model. The reviewer does not know a document exists. The document keeps describing a function signature that has not existed for four months, and the only way anyone finds out is by reading both and noticing.

That is not a discipline problem to solve with a checklist. It is a locality problem. The claim and the code that makes it true are in different places, so one can move without the other.

Annotations close the distance in three specific ways, and it is worth being precise about which.

The claim moves in the same commit as the code

Section titled “The claim moves in the same commit as the code”

The annotation is in the repository, so it appears in the diff. A reviewer looking at the findUserByEmail change sees the @exposes line in the same review, and a reviewer who does not see one on a change to a query builder has a concrete thing to ask for.

A broken reference is a build failure, not a reading error

Section titled “A broken reference is a build failure, not a reading error”
⚠ src/db.js:1: Dangling reference: #nonexistent is never defined

Delete a @threat from the definitions file while an @exposes still points at it and guardlink validate says so. A document has no equivalent: prose referring to a component that was renamed reads exactly like prose referring to one that was not.

The model is queryable, so other tools can consume it

Section titled “The model is queryable, so other tools can consume it”

guardlink parse emits the whole thing as JSON, guardlink sarif emits the open findings as SARIF 2.1.0, and the MCP server exposes both to a coding agent. A document is readable by a person and by nothing else. See Feeding findings into cxg.

This is the honest limit, and softening it would misdescribe the tool.

@mitigates #users against #sqli using #prepared-stmts is a claim that a control exists. guardlink checks that #users, #sqli and #prepared-stmts are defined, that the line parses, and that the (asset, threat) pair now has coverage. It does not read the query. Rewrite the function to interpolate the string again and leave the annotation in place, and every command still reports a clean model:

✓ All annotations valid, no unmitigated exposures.

Nothing in guardlink detects that. An annotation is a statement by a developer, recorded and checked for internal consistency, not a static analysis result. The value is that the statement is written down, dated by git, attributable, and in front of the next reviewer. It is not that the statement is verified.

@confirmed exists for the claims that have been verified by testing, and is deliberately a different verb from @exposes for that reason.

Anchors drift, and only one mode can detect it

Section titled “Anchors drift, and only one mode can detect it”

The two modes trade the same thing in opposite directions.

flowchart TB
    subgraph INLINE["inline: comments in the source file"]
        direction LR
        I1["a comment above<br/>the function"] --> I2(["moves with the code<br/>on any refactor"]) --> I3["nothing to drift,<br/>and nothing to check"]
    end

    subgraph EXTERNAL["external: .gal sidecars"]
        direction LR
        E1["@source file:line<br/>symbol:name"] --> E2(["the anchor can go stale<br/>when the code moves"]) --> E3["reanchor finds it,<br/>and repairs what it can"]
    end

    INLINE ~~~ EXTERNAL

    class I3,E3 emphasis

In external mode, a .gal sidecar records where its claims apply:

@source file:src/users.js line:3 symbol:findUserByEmail

Insert two lines above findUserByEmail and that anchor is wrong. guardlink notices, because it knows which symbol the block claimed:

⚠ 2 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.
[moved] `.guardlink/annotations/src/users.js.gal` anchors to `findUserByEmail` at `src/users.js:4`, but `findUserByEmail` is now at line 3.

guardlink reanchor --apply rewrites the ones whose symbol still exists. The ones whose symbol is gone need a human, because “the function this was about no longer exists” is not a line-number problem.

Inline mode has no drift to detect, because a comment above a function moves with it, and it also has no anchors, so ci reports:

Anchor drift: 0 (no anchored @source blocks to check)

Both of those are 0, and they mean opposite things. That phrasing is the tool being careful, and it is worth reading rather than skimming.

Both placements have a real cost, and neither is free

Section titled “Both placements have a real cost, and neither is free”
External (.gal sidecars, the init default) Inline (source comments)
Where the claim is .guardlink/annotations/<path>.gal Above the code
Visible while editing the code No Yes
Survives a refactor Anchors drift; reanchor repairs them Moves with the code
Detects a stale anchor Yes Nothing to detect
Touches source files No Yes
Works in a file you cannot edit Yes No

External keeps security metadata out of source files entirely, which matters for vendored code, generated code, and teams who will not accept comment churn in review. It costs you the thing that made annotations attractive: nobody editing src/users.js sees them.

Inline puts the claim where the next developer will read it, at the price of editing every file you annotate and losing the drift check.

The default is external. Change it with guardlink init --mode inline at the start, when switching is free.

A file the parser never read reports as clean

Section titled “A file the parser never read reports as clean”

guardlink status counts files it scanned. A file outside the include globs, or under one of the excluded directories, namely node_modules, dist, build, .git, __pycache__, target, vendor, .next, test, tests, __tests__, .bravos, and .bugb, is not scanned at all. An unscanned file contributes nothing to unannotated_files either, so it does not appear as a gap.

Adding test/users.test.js to the project on the Get Started page moves nothing:

Files scanned: 3
Files annotated: 2
Files unannotated: 1
⚠ 1 source file(s) with no annotations:
src/db.js

Test directories are the case that catches people, because a test suite is often where the security-relevant behaviour is asserted. The MCP server is the one surface that distinguishes the two kinds of empty:

guardlink_context status Meaning
annotated It has annotations.
scanned_without_annotations Parsed, genuinely clean.
not_scanned The parser never read it. Its annotations, if any, were not considered.
not_found Nothing at that path.

Asked about the test file, it says so, and names why:

{
"file": "test/users.test.js",
"status": "not_scanned",
"annotation_source": null,
"origin_files": [],
"counts": {},
"annotations": [],
"assets": [],
"open_exposures": [],
"hint": "`test/users.test.js` exists but is not in the parser's scan set — it is under an excluded directory (node_modules, dist, build, test, tests, __tests__, vendor, target, .bravos, .bugb). Its annotations, if any, were never read."
}

Reading not_scanned as “clean” is the easiest way to draw a wrong conclusion from this model.

.gal sidecars are the exception: they are found wherever the convention puts them, including for source files under an excluded directory. Annotating a test file works in external mode and does not work inline.

Put the threat model in the code when the code exists and will keep changing: the claims stay attached to what makes them true, a reviewer sees them in the diff, and a broken reference fails a command instead of going unnoticed.

Keep it in a document when you are reasoning about a system you have not built, or about something with no code to attach to, such as a deployment topology, a vendor boundary, a process. guardlink has no verb for those, and inventing one in @comment gets you a document with extra steps.

And whichever you choose, do not read a clean model as a safe one. guardlink checks that your claims are consistent. It never checks that they are true.