The threat model as an artifact
Annotations are the input. The threat model is what guardlink assembles from them: one JSON document, canonically ordered, with a content hash and a schema version. Every other output, the markdown report, the SARIF export, the Mermaid diagrams, and the MCP responses, is a projection of it.
This page describes what is in that document, what identifies one version of it, and the three ways it goes out of date.
What it contains
Section titled “What it contains”guardlink parse . writes it to stdout; guardlink artifacts . writes it to
.guardlink/model.json. The top level is fixed, in this order:
version project generated_at source_files annotations_parsedannotated_files unannotated_filesassets threats controls actors entitlementsmitigations exposures confirmed acceptances transfersflows boundaries validations audits ownershipdata_handling assumptions shields features commentscoverage external_refsOne array per verb, in the order the verbs are grouped in the grammar. There is no nesting: an exposure does not contain its asset, it names it. Resolution happens at read time, which is what lets a definition move between files without every relationship changing.
A record carries only the annotation’s own fields plus where it was written:
{ "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 }}file and line are the code. origin_file and origin_line are where the
annotation was written. It is present only in external mode, and it is what lets
a consumer point a developer at the sidecar to edit while pointing a scanner at
the source to test.
The coverage block, and what it does not mean
Section titled “The coverage block, and what it does not mean”"coverage": { "annotation_count": 6, "coverage_percent": 67}Two fields, both meaning exactly what they say. annotation_count is the number
of annotations parsed. coverage_percent is file coverage: the share of
scanned files carrying at least one annotation.
It is not symbol coverage, and reading it as “67% of this codebase is threat
modelled” is wrong in both directions. A file with one @comment counts as
covered. A file the parser never scanned counts as neither. See
a file the parser never read reports as clean.
The model’s own version moved 1.1.0 → 1.2.0 for that reshape, and the
report schema, a separate number on the merged workspace format, moved
1.0.0 → 1.1.0.
Mixing versions is loud, not silent
Section titled “Mixing versions is loud, not silent”guardlink merge normalises an old model at the point it is read from disk, and
says that it did:
✓ workspace — 2/2 repos loaded 12 annotations | 1 assets | 1 threats | 1 controls 2 mitigations | 2 exposures | 0 unmitigated ⚠ Reports use different schema versions: 1.0.0, 1.1.0. Results may be inconsistent.The merged model carries the new shape with the old report’s count folded in correctly:
"coverage": { "annotation_count": 12, "coverage_percent": 71 }Six annotations from each side. The warning is advisory: the merge succeeds and
exits 0.
What identifies a version of it
Section titled “What identifies a version of it”annotation_hash: sha256-v2:8bed94a125e3e3ed533cd5acd7936a1e985e7e3c886fbf4a0f06c30994308278A hash over the annotations, not over the run. Two checkouts with the same annotations produce the same hash, on different machines, at different times, from different commits. Identical hash means identical model.
That is why generated_at is stripped from .guardlink/model.json while
being present in guardlink parse output. The file is tracked in git, and a
timestamp inside it would churn the diff on every regeneration whether or not
anything changed. The same reasoning applies to the commit SHA: both are
reported at emission time instead of stored.
annotation_hash: sha256-v2:8bed94a125e3e3ed533cd5acd7936a1e985e7e3c886fbf4a0f06c30994308278generated_at: 2026-08-13T07:54:10.906Zgit_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 generated Mermaid file opens with the hash it was built from:
%% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts .%% artifact: threat-graph.mmd%% annotation_hash: sha256-v2:8bed94a125e3e3ed533cd5acd7936a1e985e7e3c886fbf4a0f06c30994308278%% generator: guardlink@2.0.0Every MCP tool result carries it too, as a second content block beside the payload:
{ "guardlink": { "annotation_hash": "sha256-v2:ad7d0ed9d17193cfcc17a25934d97f086969a794f49fa32644b517282bc2e829", "git_sha": null, "generated_at": "2026-08-13T07:49:34.678Z", "mode": "external", "root": "/path/to/your/project", "guardlink_version": "2.0.0" }}An agent that asked twice and got two different hashes knows the model moved underneath it. One that got the same hash knows it did not, without asking a third time.
The three ways it goes out of date
Section titled “The three ways it goes out of date”The artifacts stop matching the annotations
Section titled “The artifacts stop matching the annotations”.guardlink/model.json and .guardlink/graph/ are committed on purpose, so a
fresh clone has the model without running anything and a reviewer sees model
changes in the diff. A committed generated file looks like source, and a reader
who does not know it is derived will not think to ask whether it is current.
The check is a flag on validate:
npx guardlink validate . --artifacts✓ All annotations valid, no unmitigated exposures.
✓ Artifacts are current.It compares each artifact’s recorded annotation_hash against the model parsed
right now. Run it in CI, and a diagram that drifted from its annotations fails
the build instead of being read as fact. Never hand-edit an artifact to make the
check pass: the hash describes the annotations, so editing the file only makes it
lie.
.gitattributes, written by init, marks these linguist-generated and tells
you what to do on a conflict: regenerate, never hand-merge.
The anchors stop matching the code
Section titled “The anchors stop matching the code”In external mode a @source block records the file, line, and symbol its claims
apply to. Code moves; the recorded line does not.
npx guardlink ci .Unmitigated exposures: 1 (critical 1)Anchor drift: 2 (moved 2) of 2 anchor(s)
⚠ 1 unmitigated exposure(s): #users → #sqli [critical] (src/users.js:4)
⚠ 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.
Advisory — nothing here failed the build. Run with --strict to gate on it.moved is repairable, because the symbol still exists:
npx guardlink reanchor . --apply✓ Re-anchored 2 file(s): .guardlink/annotations/src/tokens.js.gal, .guardlink/annotations/src/users.js.galThe other three kinds, symbol_gone, file_gone, and line_gone, are not
repaired, and should not be. “The function this was about no longer exists” is a
question about whether the claim still holds, and a tool that answered it by
picking a nearby line would be inventing a threat model.
ci never repairs, even for the moved case. Rewriting an anchor inside a CI
run would move an annotation onto code nobody chose for it, on a machine where
nobody is watching.
The claims stop matching reality
Section titled “The claims stop matching reality”The one guardlink cannot detect. A @mitigates outlives the control it
describes, and every command still reports a clean model. That is covered under
what it costs.
What the artifact does give you is a diff. guardlink diff parses the model at a
git ref and compares:
npx guardlink diff HEADParsing current threat model...Parsing threat model at HEAD...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--markdown renders it for a pull-request comment; --fail-on-new exits 1
when a change introduces an unmitigated exposure. Reviewing the delta is the
closest thing to a check on whether the claims still hold, because it puts the
change to the model in front of the person who made the change to the code.
Reading it
Section titled “Reading it”guardlink parse .for the whole model on stdout, includinggenerated_at.--no-prettyemits one line..guardlink/model.jsonfor the tracked copy, withoutgenerated_at.guardlink status .for counts and the unmitigated list.guardlink ci . --format jsonfor the CI verdict asguardlink.ci/v1, with the exit code inside the payload so a JSON consumer sees the same verdict the shell got.- The MCP server for one file at a time, or a blast radius, rather than the whole document. Wire the MCP server into a coding agent.
guardlink sarif .for the open findings in a format other security tools read. See Feeding findings into cxg.
Every flag on each of those is in the CLI reference.

