Skip to content

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.

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_parsed
annotated_files unannotated_files
assets threats controls actors entitlements
mitigations exposures confirmed acceptances transfers
flows boundaries validations audits ownership
data_handling assumptions shields features comments
coverage external_refs

One 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.01.2.0 for that reshape, and the report schema, a separate number on the merged workspace format, moved 1.0.01.1.0.

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.

annotation_hash: sha256-v2:8bed94a125e3e3ed533cd5acd7936a1e985e7e3c886fbf4a0f06c30994308278

A 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:8bed94a125e3e3ed533cd5acd7936a1e985e7e3c886fbf4a0f06c30994308278
generated_at: 2026-08-13T07:54:10.906Z
git_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.0

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

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

In external mode a @source block records the file, line, and symbol its claims apply to. Code moves; the recorded line does not.

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

Terminal window
npx guardlink reanchor . --apply
✓ Re-anchored 2 file(s): .guardlink/annotations/src/tokens.js.gal, .guardlink/annotations/src/users.js.gal

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

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

  • guardlink parse . for the whole model on stdout, including generated_at. --no-pretty emits one line.
  • .guardlink/model.json for the tracked copy, without generated_at.
  • guardlink status . for counts and the unmitigated list.
  • guardlink ci . --format json for the CI verdict as guardlink.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.