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.
What an annotation is
Section titled “What an annotation is”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.
Where a document runs out
Section titled “Where a document runs out”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 definedDelete 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.
What it costs
Section titled “What it costs”It is still prose, and prose can be wrong
Section titled “It is still prose, and prose can be wrong”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:findUserByEmailInsert 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.jsTest 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.
Deciding
Section titled “Deciding”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.

