guardlink

Changelog

Notable changes to the GuardLink CLI. Sourced from the guardlink repository.

All notable changes to GuardLink CLI will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

In progress

v2.3.0

2026-10-11
TypeChanges
Changed
  • The dashboard is redesigned, and it is now one self-contained file. guardlink dashboard writes a page that makes no network request when it renders: no CDN script, no web font, no remote import(). It opens offline and under a content security policy that blocks every network origin. The page carries no inline on* handler either — every control is a data-* hook read by listeners delegated from document, and navigation is hash links — so a host can admit it without 'unsafe-inline' for scripts.
    • Seven pages in a rail replace ten: Overview, Exposures, Diagrams, Assets, Code, Agents & reach and Reports, plus Attribution with --blame. Nothing is dropped. Analytics' grids sit under the Exposures table, Explore's questions open a node's neighbourhood on Diagrams, the flow, boundary and classification tables are the data flow's table twin, the lifecycle tables sit under Assets, and the actor and entitlement tables under Agents & reach. The old page names redirect — #summary, #threats?status=open, #analytics, #explore?view=asset&subject=…, #data and #ai-analysis all land where their content now lives — so links in saved reports keep working.
    • Every diagram is drawn by GuardLink, at generation time, as SVG, and none of them is filtered to fit. The threat graph is three columns — assets → threats → controls — with one thread per exposure and per mitigation. The data flow is the whole model as ribbons, one thread per @flows, with crossings of a declared trust line in stronger ink; any node opens its neighbourhood, two hops each way. The attack surface puts every asset on a shelf with one tick per exposure. Agent reach is who acts → capability → effect, with gate bars where a human decides first. Each footer says what was drawn — "205 of 205 flows drawn" on this repository. Exposures leads with an asset × threat matrix whose cells pin and filter the table under it.
    • No layout measurement, so no NaN. Geometry comes from the data and estimated glyph widths; nothing reads the DOM to lay a diagram out. A dashboard opened in a hidden or zero-size panel — a background editor tab — draws exactly as a visible one. The page no longer contains Mermaid, d3 or marked; the re-render workarounds they needed are gone, and reports are rendered by a small built-in Markdown renderer that escapes the whole report before adding markup back, so raw HTML in a report shows as text.
    • One palette, declared once. Severity is a single warm hue with lightness by step (it passes the ordinal palette checks in both themes); mint means resolved and nothing else; the steel accent marks selection, focus and links only. A severity is a filled chip, a state an outlined chip with a glyph (✕ proven, ◐ needs review, ○ open, ✓ resolved, — accepted). The page follows the OS light or dark preference, with a toggle; --light pins light. With data-host="vscode" on its root element, every colour role is read from the editor's theme variables.
    • Diagrams gains a System model tab: the data-flow diagram with trust zones. Every endpoint a @boundary fences off sits in a dashed zone labelled with its boundaries and their tested state from the hypothesis ledger; declared assets are processes, one row each. A @boundary names a pair, not a region, so zones are derived: assets are one zone until a boundary between two of them splits it. Flows a boundary names are bold with a gate bar, and flows that leave a fenced endpoint for an asset its boundary does not name are bold and dashed, with a toggle. Element kinds (process, data store, external entity) are not in the model; they are inferred and labelled as inferred. Every element and flow is drawn, and the footer says what was.
    • The Mermaid generators and the committed .mmd artifacts are unchanged: guardlink artifacts writes the same diagrams, and the report still carries them.
  • The package's homepage is now https://guardlink.bugb.io and its author link https://www.bugb.io.
Fixed
  • Clicking a diagram mark on the dashboard opens the drawer. A shelf card, threat-graph node or system-model element used to fill a table below the legend, off-screen on a normal window, so the click looked dead. The drawer is fixed to the viewport: an asset opens with its exposures first, an endpoint with its flows, a threat or control with its claims.
  • The dashboard script runs in editor webviews whose model text contains <script. A host that adds a nonce to each <script in the page rewrote one inside the embedded model JSON, a syntax error that stopped every control on the page. Embedded data now escapes every < (and U+2028/U+2029), so it carries nothing tag-like.
  • A pair carrying two trust boundaries no longer loses one in the dashboard's flow view: both boundaries count their crossings.
  • Foreground terminal agents now receive the full built prompt automatically. guardlink annotate "<prompt>" --claude-code, --codex, and --gemini seed an interactive session with the reference, playbook, and the operator's scope and intent instead of launching with empty arguments and requiring a manual paste. Clipboard copy remains a fallback. The acceptance gate still runs on exit, and its follow-up prompts use the same delivery path; inline, IDE, clipboard-only, and stdout modes are unchanged.
  • guardlink paths no longer reports a clean result when it walked nothing. A route needs an entry and a sink, and both are flow endpoints that are not declared assets, so a model whose flows all end on declared assets — a #db, an External.* vendor — has no sink, and the walk has nowhere to go. That model used to print "No unmitigated source-to-sink paths found" and "the annotated graph holds no undefended route", which read as a pass. It now prints No annotated flow ends outside the declared assets, so no source-to-sink route was evaluated (or starts, when there is no entry) and says this is not a clean result. The same wording reaches the TUI /paths, the dashboard's Undefended routes block, and a note field in paths --json and guardlink_paths that appears only in this case. What counts as an entry or a sink, and how routes are walked, is unchanged; a model with both prints exactly what it printed before.
  • The same project now parses to the same model on every run. Files were parsed in the order the directory scan returned them, and that scan walks sibling directories concurrently, so the order varied between runs on an identical tree. Every list in the model inherited it, so the order of unentitled reaches, paths --all and other listings could change from one run to the next. Files are now sorted by path before parsing; annotation sidecars still come after source files.

v2.2.0

2026-10-07

If guardlink ci, validate or sarif gates a pipeline, expect more open exposures, not fewer. One fix in this release stops a @mitigates on one handler from clearing the same asset and threat on its sibling handlers. Every exposure it uncovers was already declared in your source; it was being hidden. The rest of the release is additive: a way to declare what an embedded LLM agent can reach, a directed @boundary, a SARIF pentest profile, declared context on every SARIF finding, a written definition of @flows with a conformance corpus, and an optional code-graph worklist for annotating agents.

TypeChanges
Upgrading
  • Run guardlink ci without --strict once before the gate does. In an inline repository that writes @mitigates per handler, a mitigation now covers an exposure in the same file only when its handler encloses the exposure's handler — the same function, or a class around the method. A mitigation in a module header or in another file covers as before. See Fixed, "A @mitigates no longer clears…".
  • Some SARIF results carry route_candidates instead of a route. A result used to take the first route declared in its file. It now takes the route on its own handler, else its file's, else its asset's inbound route, and says which in properties.route_attribution. When the model does not tie a finding to one route, the result carries route_attribution: "ambiguous" and route_candidates, and no codegraph_reachability. A consumer that reads codegraph_reachability should treat its absence plus route_candidates as "test each of these", not "no route". The routes it replaces were guesses.
  • Regenerate committed artifacts once. Every .guardlink/graph/**/*.mmd carries a generator: guardlink@<version> header, so guardlink validate . --artifacts reads them stale after the upgrade until guardlink artifacts . runs. The annotation hash version did not change.
  • A hypothesis ledger that records a boundary outcome cannot be written by an older GuardLink. 2.1.0 reads such a ledger as corrupt and refuses to write it rather than dropping the entry. Upgrade every writer of a shared .guardlink/hypotheses.json together.
  • The GitHub security-severity tags are new. A GitHub code-scanning upload now files GuardLink alerts with a severity band, and, without an explicit category, under guardlink/threat-model/.

On the version number. No command, flag, library subpath, JSON field or SARIF result was removed, renamed or reordered, and the model and SARIF schema ids consumers pin to are unchanged; every new JSON field and SARIF member is additive and optional. One TypeScript change is not purely additive: AnnotationVerb gained 'agents', 'reaches', 'effects' and 'gates', and the Annotation union gained ReachesAnnotation, EffectsAnnotation and GatesAnnotation. Code that reads verb or compares it with a literal is unaffected. Code that switches over it exhaustively with an assertNever-style never default stops compiling until it handles the four verbs or adds a default branch — the same kind of change the 2.0.0 entry below describes for 'actor' and 'entitles'. This release ships as a minor on the precedent 2.1.0 set, which widened the exported DiagnosticCode union (unrecognised-comment-form, uncommented-annotation and others) in a minor release. The widening is stated here so nobody has to learn about it from a compiler error. DiagnosticCode gains members again in this release.

Security
  • @modelcontextprotocol/sdk is raised to ^1.31.0 (GHSA-6qxp-vccf-f47h, CVE-2026-104850, high). The advisory is in the SDK's OAuth client, which could send credentials to an authorization server the MCP server chose. GuardLink does not use it: guardlink-mcp is a server, and serves over stdio only. The bump clears the advisory from npm audit for anyone installing GuardLink.
Added
  • The agent part of the threat model, in the report and on the dashboard. What @agents, @reaches, @effects and @gates declare is now drawn where people read the model, from one derived view (src/reach) so the two cannot disagree (SPEC §3.2.1, Where they are read):

    • guardlink report gains an Agents and LLM Reach section when any reach annotation exists: per actor, each capability, whether it is entitled and what it leads to; the reach map (actor × asset, capability and effect per cell); unentitled reaches with each near miss's reason; ungated mutations (an effect other than read with no @gates in front of it); gates; egress from a reached surface; open exposures on reached assets; and the OWASP Top 10 for LLM Applications rows — LLM06 Excessive Agency, LLM01 Prompt Injection, LLM05 Improper Output Handling. A model without reach annotations reports exactly as before.
    • guardlink report --agents (and guardlink_report with agents: true) writes that section as a document of its own, threat-model-agents.md, so an LLM threat model can be published alone or as part of the whole one.
    • The dashboard gains an Agents & Reach page (the reach map, the same lists and the OWASP rows, with an empty state when nothing declares reach), an Agent Reach diagram tab, an Actors table on Data & Boundaries marking each actor an AI agent, principal or approver, a Reach block in the asset drawer, and "What to do next" items for unentitled reaches and ungated mutations.
    • guardlink threat-report hands the LLM the reach claims and the same derived lists as agent_reach, and its prompt asks for an agents section mapped to the OWASP LLM rows.
    • An effect is tied to an actor only when a reach of that actor is bound to the same code, and whether it is gated comes from classifyEffects, the join reach_analysis and the SARIF export use, so every surface gives one answer.
  • Reach: what an embedded LLM agent can access, as annotations. An application that embeds an agent hands it capabilities through its harness — tool definitions, MCP servers, file, shell and database access — and none of that was declarable, so guardlink diff did not move when someone registered a run_sql tool. Four relation verbs declare it, each where its fact lives (SPEC §3.2.1):

    • @agents <agent actor> to <capability> [on <asset>] [as <identity>] on the tool registration, and @reaches in the same shape for a principal that is not an LLM agent (a CI runner, a service account). Writing @agents about an actor is what marks it as an agent, and one actor named under both verbs is a validation error, agent-reach-conflict.
    • @effects <read|write|delete|execute|spend|notify> on <asset> [as <identity>] on the code that acts. The effect set is closed, like @handles; egress stays on @flows … -> External.X.
    • @gates <asset> by <approver actor> [for <capability>] on an approval step. by is required; there is no advisory/blocking qualifier.
    • Can minus may. The capability is the token @entitles uses, so guardlink_lookup("unentitled reaches") lists every reach no cited entitlement for the same actor, capability and (when the reach names one) asset covers, with near_misses saying why an entitlement that almost matched does not count. New lookup forms: reaches, agents, unentitled reaches, effects, gates, each with an optional for <actor|asset>; actors rows gain agent and reaches. guardlink diff reports reach, effect and gate changes and a New Unentitled Reaches section.
    • guardlink lookup <query…> answers the same forms from the CLI as JSON; --fail-on-found exits 1 when the answer is non-empty.
    • All four verbs are agent-writable: guardlink_annotate_apply and the annotate gate accept them, and @entitles and @accepts stay refused. validate reports an undeclared actor or approver (undeclared-actor) and undefined assets (dangling-ref). None of them changes the SARIF export. Models without them hash and export exactly as before.
  • Reach reaches the exports. The model JSON, guardlink_lookup and the SARIF pentest profile now carry what the reach verbs declare, keyed by one claim key so a dashboard, an IDE plugin, a canvas and a scanner can join them (SPEC §5.5, §6.8; docs/GUARDLINK_REFERENCE.md, Reach in the exports).

    • reach_analysis in the model JSON (guardlink parse, report --format json, .guardlink/model.json, MCP guardlink_parse and guardlink://model): version: 1, a summary, unentitled_reaches with each near miss and its reason, and every mutating effect with gated, the gates that cover it and the gates on its asset that do not (capability-unknown, other-capability). Each row names its claim by index and claim key. Written only when the model declares a reach, effect or gate, so other models export the same bytes as before; the annotation hash never moves.
    • Gated and ungated effects (SPEC §3.2.1). A gate covers an effect on its asset when it names no capability, or when every @agents written in the effect's doc-block is the capability it names. New lookup form ungated effects [for <asset>]; unentitled reaches rows gain claim_key.
    • guardlink/agent-reach in the pentest SARIF profile: one kind: "review" result per @agents/@reaches and per @effects other than read, appended after the boundary claims, with the actor, agent flag, capability, asset, effect, gated and gates in properties. Every reach is listed, entitled or not, and every mutating effect, gated or not: nothing in SARIF may be derived from @entitles, and a gate suppresses nothing. The github profile is byte-identical. The three existing pentest fixtures gain the rule only; tests/fixtures/sarif-pentest/support-desk.sarif is new.
    • .guardlink/model.json now writes reaches, effects and gates in canonical order, like every other relation.
  • An optional code graph tells the annotating agent where to start. guardlink annotate told the agent to find the entry points and trace each one to its sinks, and the coverage playbook asked for "entry points first" — but nothing in GuardLink could find an entry point: everything the prompt carried about the repository was derived from annotations already written. When a code graph is installed (codegraph-mcp on PATH, $GUARDLINK_CODEGRAPH_MCP, or bravos graph) and the repository is indexed, the prompt now carries a bounded code-graph worklist: route handlers ranked by the sink classes they reach through the call graph, each with its METHOD path, file:line, an example sink, and whether the model already covers it (none / file / handler), unannotated handlers first. The coverage playbook takes it as its "entry points first" order.

    • Two new MCP tools give an agent annotating in its own editor the same answers: guardlink_worklist (the ranked list, filterable by file) and guardlink_reach(symbol) (the sink classes one function reaches with an example path each, and which route reaches it). guardlink_annotate gains code_graph: false.
    • The graph is strictly optional. GuardLink never installs or builds one. With no tooling, no graph built, tooling too old to answer, a failed query, --no-code-graph or GUARDLINK_CODEGRAPH=off, the annotate prompt is byte-identical to the one this version builds without the feature; annotate says on stderr why no graph was used, unless it was switched off.
    • A thin graph is not a small attack surface. When the graph reports insufficient_resolution — its own measurement that too few calls resolved to walk — the worklist is withheld and the prompt says so, with the graph's reason, instead of showing a short list.
  • The worklist carries each route's access level, and the prompt suggests boundaries from it. When the code graph also answers codegraph_auth_boundary, every worklist route carries its level — public, authenticated, elevated, or unknown with the graph's reason — with the basis and the guard text it rests on (guardlink_worklist returns them as access, with code_graph.auth_verdict and resolver_density). Levels are carried only under the graph's classified verdict; any other verdict withholds them and the prompt says why, and unknown is never rendered as public. The annotate prompt's worklist adds Boundaries the graph suggests for the rows it shows: a caller boundary per classified route, naming the cited guard as its enforcement, and a data, process, egress or filesystem boundary per sink class the handler reaches, anchored at the example sink. A graph that does not publish the query yields the worklist exactly as before; with no graph the prompt is unchanged.

  • guardlink validate --code-graph checks declared boundaries against the graph. Also guardlink_validate with code_graph: true. Four warnings, never findings and never SARIF results: boundary-access-contradicted (a boundary, or an @assumes on its inner asset, says the inner side is behind a login or an admin check, and a route there is classified below it), boundary-access-unknown (the same claim where the graph could not decide), boundary-missing (a route handler reaches a classified sink and no asset its file names has a boundary to the outside) and boundary-unused (no declared flow crosses it and no recognised entry point reaches either side). The outer side is inferred as guardlink paths infers entries: an undeclared endpoint or an External.* asset. Without the flag nothing is consulted.

  • Declared boundaries are claims the hypothesis ledger can record. guardlink hypothesis contradict|support <target> records a boundary contradicted or supported against its claim key, with evidence (a contradiction is held to the confirmation's bar), and guardlink hypothesis boundaries [--state <state>] [--json] lists every boundary's state (guardlink.boundary-claims/v1). A target is the claim key, the #id, or the @boundary's file:line. Outcomes expire with the code as exposure outcomes do. confirm --from-scan records a finding whose claim key names a boundary as that boundary's outcome, where it used to report it stale, and reads boundary_claim_key/boundaryClaimKey beside an exposure's key, recording both. A boundary outcome never moves an exposure's state and is never written into source. Compatibility: a ledger holding a boundary outcome reads as corrupt to an older GuardLink, which then refuses to write it rather than dropping the entry.

  • @flows has a complete definition, and a conformance corpus to test a reader against. SPEC §3.2 @flows now specifies every form the parser accepts: the four endpoint forms (a #repo.id tag such as #billing.charge is a repository-qualified id, not a sub-component of #billing), chains and the one-record-per-hop rule, the optional via and what a flow without one means, route channels METHOD./path and how the method and path are read, and how a flow in a .gal sidecar takes its location from its @source block. conformance/flows.json is that definition as annotation text in and expected records out. It ships in the npm package (guardlink/conformance/flows.json), and tests/conformance-flows.test.ts runs it against this parser. See conformance/README.md.

    • Every flow record in the model carries route: { method, path } when its mechanism is a route channel, and null otherwise. The field is additive and derived from mechanism, so the annotation hash and claim keys do not move.
    • SPEC §3.6, Handler Scope, defines the code each claim is attached to and the two answers that depend on it, described under Fixed below.
  • The SARIF export carries the model around each finding, without changing a single existing result. Each @exposes and @confirmed result now also carries, as SARIF members existing consumers ignore: logicalLocations naming the handler it is attached to, and its anchor; taxa for its cwe: and owasp: references, with run.taxonomies; relatedLocations for the @boundary, @assumes, @handles, @transfers and @audit annotations on its asset; and codeFlows with the declared @flows chain into it, each hop at its own line and boundary crossings marked. The run gains graphs[0] — every @flows and @boundary as an edge with its claim key, each boundary with the flows that cross it and, when exactly one side is undeclared, which side is outer — plus automationDetails, versionControlProvenance when the repository has a web-hosted origin, and properties.sarif_profile_version: 1. Rules gain help, tags and a GitHub security-severity band (confirmed 9.0, critical/high 8.9, other exposures 5.0; diagnostics are not tagged as security). See SPEC §6.6 and §6.7.

    • Nothing existing moves. No result is added, dropped or reordered, and no rule id, message.text, fingerprint or existing property changes. tests/sarif-enrichment.test.ts deletes every new member and compares the rest, byte for byte, with exports cut before this change, and validates every export against the official SARIF 2.1.0 schema. No new member is derived from @entitles or @actor.
    • A chain is emitted only when its last hop is the claim's own. @flows hops are not linked by call site, so the hop into the asset must be declared on the claim's handler or file (guardlink/flowAttribution); otherwise the result has no codeFlows. Upstream hops joined only by graph adjacency say so (guardlink/hopAttribution: "graph").
    • Expect a GitHub upload without an explicit category to file GuardLink alerts under guardlink/threat-model/.
  • guardlink sarif --profile pentest exports what a test run needs. The default profile, github, is the export above, byte for byte. The pentest profile appends, after every github result so no result index moves: one guardlink/mitigated-exposure result per covered @exposes, said exactly as an uncovered one would be, with suppressions[] naming each covering @mitigates or @accepts and where it is; and one guardlink/boundary-claim result per @boundary (kind: "review", level: "none"), keyed by the boundary's claim key, with its sides, the outer side when exactly one side is undeclared (basis: "unknown" and no side otherwise), the @flows that cross it and the routes into its inner side. Every exposure, confirmed and boundary-claim result carries the hypothesis ledger's state as properties["guardlink/hypothesis"]. --baseline <file> sets each result's baselineState (new, unchanged or updated) against an earlier export, matching by claim key; a baseline result that matches nothing is counted in properties.baseline.absent, not exported. The MCP guardlink_sarif tool takes profile too. See SPEC §6.8.

    • tests/fixtures/sarif-pentest/ holds the pentest export of two fixtures, with a ledger, for a SARIF reader to test against; the test suite regenerates and compares them, and validates every pentest export against the SARIF 2.1.0 schema.
  • @boundary can say which side is outside: @boundary from <outer> to <inner>. A boundary was an undirected pair, so the only way to tell its untrusted side was to infer it from an endpoint no @asset declares — which says nothing when both sides are declared assets, the usual case for an application and its database. The directed form states it. between, and and | are unchanged and remain undirected. See SPEC §3.2 @boundary.

    • The model records directed: true on such a boundary, with asset_a the outer side and asset_b the inner side. Undirected boundaries carry no new field, and their annotation hash and claim key do not move; declaring, dropping or reversing a direction changes both, so a recorded test outcome does not carry over to a boundary whose direction changed. Read from inline comments and .gal sidecars alike.
    • The SARIF export states a declared side as basis: "declared", distinct from the inferred undeclared-endpoint and from unknown: in each boundary edge's guardlink/side in run.graphs[0], where a directed boundary is also guardlink/directed: true running outer to inner, and in the pentest profile's guardlink/boundary-claim results, whose inner_routes now also list routes into a declared inner side. An export of a model with no directed boundary is unchanged, byte for byte, in both profiles except for the guardlink/boundary-claim rule's help text, which now describes the three bases; tests/fixtures/sarif-pentest/ is regenerated for it and gains boundary-direction.sarif, an export with all three.
    • guardlink validate (and the MCP and TUI validate) reports a directed boundary whose outer or inner side names no declared asset and no @flows endpoint as an error, unresolved-boundary-side. Undirected boundaries are not checked. guardlink diff reports a change of direction, and validate --code-graph checks a directed boundary's declared inner side, including one between two declared assets that it used to skip.
    • The annotate prompt, the map playbook, the code-graph boundary suggestions and the generated agent instructions teach agents to write the directed form when the trust direction is visible in the code, and between otherwise.
    • conformance/boundaries.json pins the grammar (directed, undirected and malformed lines), the side the SARIF export states for each, and which directed boundaries validation rejects. It ships in the npm package beside flows.json, and tests/conformance-boundaries.test.ts runs it against this parser and exporter.
Fixed
  • The MCP guardlink_sarif tool reports dangling references. It passed an empty list where guardlink sarif computes them, so the same model gave two different SARIF documents depending on the front end. Both now compute them the same way.
  • --min-severity says what it filters. It has only ever filtered unmitigated exposures; a @confirmed result is a reproduced exploit and is always exported. The help text, the option's documentation and SPEC §6.1 now say so, and a test pins it.
  • SPEC §6 describes the SARIF GuardLink exports. It promised results for @accepts, @assumes, @audit, @mitigates and @handles, low severity as note, a cwe result property and TS200x rule ids, none of which the exporter produced. §6.1–§6.4 now state the five rules, their order and levels, what each other annotation becomes and why it is not a result; §6.6 and §6.7 are new.
  • A route belongs to the handler it is declared on. SARIF used to attach routes per file, first declaration wins. In a file that declares GET /orders/<id> on one handler and POST /pay on another, an injection on the POST /pay handler was exported with codegraph_reachability set to GET /orders/<id>, and a consumer probed the wrong endpoint. Each exposure and confirmed result now takes the route declared on its own handler, else its file's route, else its asset's inbound route. properties.route_attribution (handler, file or asset) says which step found it. When the model does not tie a finding to one route, the result carries route_attribution: "ambiguous" and route_candidates, and no codegraph_reachability. Expect some results that used to carry a route to carry candidates instead. Those routes were guesses.
  • A @mitigates no longer clears the same asset and threat on a sibling handler. Coverage was keyed by (asset, threat) for inline annotations. One @mitigates #orders against #idor on update_order therefore removed the @exposes #orders to #idor on get_order and on delete_order from validate, ci and sarif. When an exposure and a mitigation in the same file are both attached to handlers, the mitigation now covers the exposure only if its handler encloses the exposure's handler: the same function, or a class around the method. A mitigation in a module header, or in another file, covers as before. Expect an inline repository with per-handler mitigations to report more open exposures. Each one was already declared and was being hidden. Measured: no exposure changes state on this repository or on the external-mode test fixture.
  • Every hop of a multi-hop @flows gets the whole line. In a .gal sidecar, only the first hop of A -> B -> C was sited at its @source block; the others were sited at the .gal file itself, so they had no handler and no anchor and pointed readers at the sidecar rather than the code. And a -- "…" continuation line extended the description of the last hop only. Both now apply to every hop, as SPEC §3.2 @flows (Chains) states, and conformance/flows.json pins them.
  • @flows A -> B via -- "d" is reported as malformed. It used to record -- "d" as the mechanism and lose the description. via must be followed by a mechanism, the same rule that already rejected a trailing bare via.
  • The annotation guides no longer forbid chains. The agent prompt and docs/GUARDLINK_REFERENCE.md said "ONE source → ONE target per line", which the parser has not enforced since chains were added. They now describe one path per line, with chains, and show the route form.

v2.1.0

2026-09-17

Read this first if guardlink ci --strict gates a pipeline: this release can turn a green build red, and your exposure count will go up. Nothing in your code got worse. GuardLink was not reading every annotation you had already written, and now it is.

Two parser defects, both fixed here, discarded annotations before they reached the threat model — silently, with no diagnostic, and with validate printing its all-clear over them:

  • Doc comments were dropped. The parser stripped exactly one comment marker and then required the very next character to be @. Every doc-comment convention is a marker plus one more character, so /// @exposes arrived as / @exposes and was thrown away, one character out of reach. That covered Rust /// and //!, Doxygen, C#, Swift and Dart doc comments, the opening line of a /** */ block, and ##, ;;, %%, ---, -- | and '''.
  • 37 of the 43 languages whose comment styles the parser recognises were never opened. The markers were all supported; the file glob listed the extensions of six of those languages. A correctly written, correctly commented @exposes in .php, .pyi, .kts, .erl, .clj, .vb, .ml, .pl, .tex and 28 more was read by nothing, and nothing said so. 34 source extensions are scanned in 2.0.0; 73 are scanned here.

Measured, on identical fixtures. One unmitigated @exposes in a Rust /// doc comment: guardlink ci . --strict exits 0 under 2.0.0, printing "✓ No unmitigated exposures, no anchor drift.", and exits 1 here, naming the exposure at its file:line. The same annotation in a .php file gives the same pair of answers. The source tree is byte-identical across both runs; only the reader changed.

Why the old behaviour was a defect rather than a promise. A threat modeller's all-clear is a claim that it read your claims. GuardLink was printing that tick over lines it had never parsed, so the one output a gate is built on — no unmitigated exposures — could mean your risks are covered or we could not read the lines declaring them, with nothing anywhere to tell the two apart. Reading the annotations is the only direction the fix can run in, and hidden risk becoming visible is the good direction to be surprised in.

What you should expect to see. More annotations parsed, so exposures, mitigations, boundaries and flows all move up, and coverage percentages with them. Where an annotation is nearly right you may now get one of two new warnings — unrecognised-comment-form and uncommented-annotation — in place of silence. Every finding that appears was already in your source: the diff worth reading is your own annotations, not this release's.

TypeChanges
Upgrading
  • Run guardlink ci without --strict once before the gate does. The new findings are claims your own team wrote; read them before a pipeline fails on them.
  • If there are more of them than you want to fail a build on today, narrow the gate instead of removing it. guardlink ci gains --severity critical,high and --scope services/api in this release for exactly this case, and --min-coverage so that a model nobody has written yet stops passing by having nothing to fail.
  • Regenerate committed artifacts once. ANNOTATION_HASH_VERSION is 3 and ARTIFACT_SCHEMA_VERSION is 2, so every committed .guardlink/ artifact and every synced agent instruction block reads stale on first run after upgrade. guardlink artifacts . and guardlink sync clear it; guardlink validate . --artifacts says which are outstanding.
  • Nothing needs migrating. No command and no flag was removed, every new library subpath and every new JSON field is additive, and the schema ids consumers pin to are unchanged.

On the version number. This project scopes a major version to two things — the TypeScript type surface and the threat-model JSON schema — and states the rule in the 2.0.0 entry below. Neither moved here. The one entry below labelled Breaking is on the guardlink/hypothesis subpath, which 2.0.0 does not export and no released version ever has, so it has no released consumer to break. The parser change is a behaviour change that this versioning rule does not describe at all, which is why it leads these notes rather than the number.

Added
  • Annotations written where init told you to write them now say whether anything reads them. guardlink init defaults a new project to annotation_mode: "external" and its own last line of output says to put annotations in .guardlink/annotations/<source path>.gal — "NOT in source files". GuardLink reads those sidecars perfectly. Nothing downstream of it does. Measured on a fresh repository: the same two @exposes and one @accepts gave exposures 2, acceptances 1 written inline and exposures 0, acceptances 0 written in the sidecars init recommends, while the consuming surface still reported itself present — so a repository annotated exactly as its own tool instructed rendered a green, empty dashboard, and nothing anywhere said the data had been dropped.

    • The mechanism is not in the sidecar parser. Consumers do not run it. They look for a JSON export and, finding none, fall back to scraping inline source comments themselves; an inline annotation survives that fallback and a .gal cannot, because gal is in none of the source extensions such a fallback walks. External mode therefore has a step inline mode does not — guardlink parse . -o .guardlink/report.json — and until now nothing in GuardLink said so. That is the whole defect: not a parser that cannot read sidecars, an instruction missing its second half.
    • init now prints that step, .guardlink/README.md and every generated agent instruction file carry it under external mode, and docs/GUARDLINK_REFERENCE.md documents it beside the .gal syntax it belongs to.
    • validate and status report the export, so the gap is caught after the first annotation rather than at the dashboard. guardlink validate . prints what consumers currently see and the command that fixes it; guardlink status . carries a Consumers read: line. Four verdicts, not two: missing, stale, not-needed (a purely inline repository needs no export, and a warning there would be an alarm fired on the correct configuration), and unverifiable — an export carrying no metadata.annotation_hash is not known to be current and not known to be stale, and saying either would be a guess printed as a fact.
    • The tick keeps its meaning. With a broken handoff, validate's all-clear reads "✓ All annotations valid, no unmitigated exposures — in this repository", because the annotations are valid here and absent everywhere else.
  • A .gal sidecar that yields nothing now says which kind of nothing. An empty sidecar and a sidecar full of prose both left guardlink validate printing "✓ All annotations valid" — the same confident zero, one layer earlier, over a file a developer created on purpose. Three causes, three codes, because the fixes differ: empty-gal (the file was created and never written), unrecognised-gal (it holds text that names no GuardLink verb), and missing-gal-source (its @source points at a file that is not on disk, so the annotations parse, count, and describe no code — while the named path is reported as an annotated file that does not exist). A sidecar whose lines are verb-shaped and fail to parse keeps its existing, more precise malformed-annotation / prose-like / unknown-verb diagnostic instead of gaining a vaguer second one: the rule is nothing came out and nothing said why. missing-gal-source is keyed on existence on disk and not on the scan set, so a @source naming a file under test/ — excluded from the source scan, annotated through its sidecar by design (GL-503) — does not fire it.

  • guardlink hypothesis next is one queue in three renderers: bounded the same way, and addressable by key. bravos asks GuardLink what to test next and then addresses the answer; both halves of that had a gap.

    • -n bounds the table, --intake and --json identically. --intake ignored it and printed the whole queue, so the renderer built for the downstream consumer was the one that could not be asked for a bounded brief — -n 3 --intake printed all 142. The bound now happens once, before any renderer runs, so a renderer added later inherits it. This changes a default: --intake with no -n prints the first 10, as the table and --json always did; -n <count> asks for more.
    • A bounded queue names its total, in all three renderers — because a bounded brief that does not say it is bounded is a misleading one, and the failure is an operator handing bugb intake a 10-item plan off a 142-claim queue believing nothing else is outstanding. The table header reads 10 of 142 to test, the brief says it above the list and again on the line that hands it over (with the flag that asks for the rest), and the payload carries total, the queue length before the bound. A run that hides nothing reads exactly as it did before, down to the byte: no marker, no notice.
    • guardlink.hypotheses-next/v1 entries gain key, the claim key — the same one guardlink.hypotheses-list/v1 records carry and hypothesis confirm --from-scan joins on. A consumer addresses a queued claim by key instead of joining back to hypothesis list on (asset, threat, file, line), which is positional and collides — the same tier the SARIF threatId fix in this release is about.
    • The schema stays at /v1, deliberately. key and total are purely additive; nothing /v1 carried is renamed or removed, and a consumer tells whether a build has them by the field's presence rather than by a version string it would have to be rebuilt for. Bumping would strand consumers pinned to /v1 for a change that breaks none of them.
  • guardlink ci warns before a signed risk acceptance lapses, and the boundary is the server register's. The gate could tell you an acceptance had expired — its exposures come back and --strict fails — and said nothing on the way there. Measured: an @accepts covering a critical exposure with eleven days left printed Acceptances: 1 in the model; 0 do not count and then an unqualified green tick. The horizon was in the model the whole time; the gate declined to read it out, and annotations-in-code is the flow we document and demo.

    • One judgement, not two. The warning fires at until − 14 days, which is DEFAULT_ACCEPTANCE_WARN_DAYS in the server's bugb_server/notify/config.py and the boundary its acceptance-deadline scan warns at. Same event, same waiver, same team — two components disagreeing about whether a risk acceptance is in trouble is a worse defect than either of them being silent. The ceiling (365, MAX_ACCEPTANCE_WARN_DAYS) and the reading of 0 (no warning at all) are matched too.
    • It warns and never gates. Nothing is wrong with an acceptance doing what its author signed it to do, and this gate sits in people's pipelines: a red build on a date nobody chose — no commit, no review, just the calendar — is a gate teams delete rather than act on. That is also the server's verdict: ACCEPTANCE_EXPIRING leaves lifecycle_state at accepted, and only EXPIRED_REOPENED moves it. When the date passes, the acceptance stops covering and --strict fails on exposures, which was already load-bearing.
    • The green tick carries its own caveat. The count line indexes all three states at once (3 in the model; 1 do not count as acceptances; 1 lapsing within 14 days), the detail block names the file, line, signer, days left and date, and says what happens when it lapses. Only acceptances that currently count are warned about: an expired one is reported as expired and an unqualified one as unqualified, because telling a reader that a record covering nothing will shortly stop covering nothing is noise in front of the finding that matters.
    • Per project in .guardlink/config.json (acceptance.warn_days, clamped 0..365 exactly as the server clamps it) and per run with --expiring-within <days>. The config clamps, the flag refuses — the file is read by a machine and may have been written by an older build; the flag has a person behind it, and silently honouring 365 for someone who typed 3,650 is how a gate ends up meaning something nobody asked for. JSON gains summary.expiring_acceptances, summary.acceptance_warn_days and the expiring_acceptances findings.
  • guardlink ci --min-coverage — a floor, so an empty model stops passing the gate identically to a finished one. Every check the gate ran counted things that are wrong, and a count of things that are wrong is silent about whether anybody looked. Measured: a repository with zero annotations and a repository whose every risk is mitigated produced the same line and the same exit code, so a pipeline could be green because the work was finished or because it never started. That is #vacuous-pass in the tool that exists to catch it.

    • The coverage line is unconditional — Annotation coverage: 0% — 0 of 12 source file(s) annotated, 0 annotation(s) — because it is the denominator every count above it is measured against, and printing it changes no exit code and breaks no pipeline. The JSON says what it counts (summary.coverage: kind, annotated_files, source_files, percent, annotations) rather than leaving a consumer to infer a denominator, which is the D42/D49 lesson applied to the gate.
    • The floor is opt-in and gates on its own, without --strict. Opt-in because a floor that appeared on upgrade would turn a green pipeline red for a change nobody made. Independent of --strict because --strict says treat findings in this model as blocking while a floor says this model has to say something before its findings mean anything — behind --strict it would be unreachable for everyone running the gate advisory, which is a floor nobody can fail. --min-coverage 0 is a real value that prints the verdict and never fails; a value outside 0..100 is refused rather than clamped.
    • Scoped like everything else. With --scope, the floor measures the scoped area only, through the same prefix predicate the findings use — otherwise a fully annotated web/ would carry an untouched services/api/ over the bar, which is the same vacuous pass one level up. The division still happens in the one place it already did (fileCoveragePercent).
  • A repository with no threat model no longer gets a CLAUDE.md saying it has one. init wrote "This project carries a GuardLink threat model… Ask it instead of inferring security context from the source. It already answers most of what you would otherwise guess at" into every repository, including one with {exposures: 0, assets: 0, threats: 0}. That is false, it is false inside the customer's own tracked files under our name, and — worst of the three — it is an instruction: every coding agent opening the repo was told to consult an empty model in preference to reading the code, which is exactly the reading that turns an absent finding into a cleared one.

    • Deleting the sentence was not the fix. A repository that has just run init is precisely where an agent most needs telling what to do, so the empty state gets its own text: the model is empty, an absent finding is not a cleared one, read the code for security context exactly as you would with no threat model at all — and you are the one who fills it, with guardlink sync turning the block into a real one once annotations land. The obligation half of the block was always true and is unchanged. It converts the first five minutes from an embarrassment into an on-ramp.
    • The claim and its evidence now key off one fact. annotations_parsed > 0 already decided whether the live model context was appended below the block; it now also decides whether the block claims a model above it, so the file cannot assert a threat model in the one case where it goes on to show you nothing.
    • And the other unconditional claim in the same file: the reference pointer. The block said docs/GUARDLINK_REFERENCE.md regardless of where the reference actually went — so in every --no-root-files repository, where init writes .guardlink/GUARDLINK_REFERENCE.md, the one pointer aimed at a reader about to guess at syntax pointed at nothing. This is D45 exactly, in the file D45 did not reach; init now passes the path it wrote by and sync observes where the file is, from the same rule the .guardlink/README.md writer already used.
  • guardlink estate — what is open across every repository, read back from the file merge already writes. guardlink merge --json has produced a combined threat model since workspaces shipped and nothing in the product ever read one: the estate answer existed on disk, and the only ways to consume it were a browser and a text editor. The question a platform team actually asks — what is open across all of our repositories — had no command.

    • One answer, not a second opinion. estate computes coverage through the same openExposuresIn the merge's own totals use, with the same owner scoping, so the number a merged report carries and the list a reader gets out of it cannot disagree. Findings are placed in the repository they were written in, worst severity first; @confirmed reproduced exploits are reported separately, because no acceptance silences one anywhere in the product.
    • It says how much of the estate it read, first. A merged report knows how many repos it was asked for and how many it loaded. "0 open" over 0 of 4 repositories is the vacuous green one level up, and it is reachable by the same two routes it is reachable in merge — an unexpanded glob, and a pipeline that never uploaded. So read_nothing and partial are fields on the summary, they are the first thing the text output says, and under --strict an estate that read nothing exits 1 rather than reporting clean.
    • It refuses a file that is not a merged report. A per-repo threat-model.json and a merged report are both JSON with a model-shaped body; pointed at the wrong one, a lenient reader would answer "0 repos, 0 open" — a clean estate assembled from a file describing one repository. The four fields only a merge produces are required, and a file missing any of them is refused by name with the command that produces one.
    • --severity, --repo and --format json (guardlink.estate/v1), with the same rule ci holds: an unrated finding is not a low one and --severity never drops it. Advisory by default; --strict is the opt-in. guardlink merge --json now prints the estate line that reads what it just wrote, and examples/ci/workspace-merge.yml runs it into the job summary.
  • Explore: ask a question, see the part of the graph that answers it. The Diagrams page draws the whole model. GuardLink's own model is 43 nodes and its data flow diagram 67, against a measured legibility ceiling of about 12 — so on the repository the tool ships from, those pictures have never been readable, and at the fitted zoom you are looking at 5–27% of one. This is not a large-codebase problem; it is a more-than-a-dozen-components problem, and every real repository is past it in week one. The previous release made an undrawable diagram say so. This one is about the diagrams that draw perfectly and still tell a reader nothing.

    • A legibility budget, measured rather than chosen (src/graph/legibility.ts). ~12 nodes / 16 edges, derived from rendering 21 real subgraph slices of this repository's model at the dashboard's Mermaid settings and measuring the layout each produced against the panel it has to fit: #blame at 11/15 was the last that fitted with legible labels, #cli at 14/21 the first that did not. It is a second, strictly tighter question than render-budget.ts's — can anyone read it, against will anything draw it — and between the two limits sits every whole-model diagram GuardLink has ever emitted. The node counter was checked against browser-measured SVG geometry: it reports 29 nodes / 70 edges for the default threat graph, which is what Chrome draws.
    • A new Explore page, with seven views, each stating what it is for. Where the risk is (the asset × threat matrix, now the entry point, every cell a drill-down), one component, one weakness class, one trust line, undefended routes, blast radius from a file, and what to fix next — plus what a branch touched under --since. Every view renders its question verbatim above the answer, because a view that exists because the data allows it is not the same as one somebody wants.
    • Bound the answer, not the hop count. depth is not a size control on a real model: measured here, depth 2 from any declared asset returns 36–40 nodes of a whole-graph 43, and depth 3 is identical because the walk has saturated. growWithinBudget grows a neighbourhood outward and stops when the drawing stops fitting, then names what is one hop outside the frame — adjacency, not everything reachable, which on this model is the difference between about a dozen and 52.
    • The planes are split. "What is this exposed to" and "what does it talk to" are different questions and no longer share a canvas; that fusion is what made the existing focus dropdown a 20-node hairball. They get one budgeted diagram each, from the two generators that already existed.
    • The classification layer is not drawn as a node-link diagram. Of this repo's annotations, 24% are graph-shaped (@flows, @boundary, @transfers) and 76% classify. Drawing a classification as edges is drawing an incidence matrix as a graph, and it is what manufactures the hubs. So one threat across N components is a list, the whole classification layer is the matrix, and the star is drawn only when it is one component's own plane and fits.
    • Narrow, do not refuse, and never render something illegible. A component's threat plane that does not fit is narrowed to high and critical, saying how many claims that hid; if that still does not fit it is not drawn, and the rows carry every claim. A file naming more components than can be drawn together has its set trimmed and says so. A component the reader named is never dropped.
    • Explore diagrams are drawn at full size and never shrunk to fit. The 0.6 fit clamp exists so labels stay legible and does not achieve it — Mermaid's label font is 11px, so 0.6 is 6.6px, and every whole-model diagram measured sits exactly at the floor. On a budgeted diagram it can only take away what the budget bought: measured, src/mcp/server.ts's blast radius is 7 nodes and 16 edges and lays out 1,905px wide because its flow labels are long — fitted, its labels came out at 6.1px. Explore panes now render at natural size and the panel scrolls. The whole-model Diagrams page is unchanged.
    • The whole-model diagrams now state their own size. Kept, because on a small repository they are the right picture, and labelled on the page and in the committed .mmd header when they are past readable, pointing at the surface that answers the same question legibly. Nothing illegible is presented as though it were fine.
    • The committed artifacts gained the slices that are readable. by-asset/<id>.threats.mmd and by-asset/<id>.flows.mmd, by-boundary/<id>.mmd, and an index.md that is a table of slices and the question each answers — Markdown, so it renders on GitHub and on a docs site at any model size. Each slice is selected by the same budgeted code the page uses; one that cannot be made legible is not written, and index.md names it. A change to one component now touches one file instead of rewriting the whole-graph artifacts. This works on repositories that never write @feature, which was the gap in by-feature/ — the density hedge gated on a tag most projects never apply.
    • Unchanged, and tested: a repository within budget still emits byte-identical .mmd files, validate --artifacts still answers freshness and drawability separately, and both still exit 0. tests/query-views.test.ts pins every claim above, including that no view can return a drawing past the budget.
  • guardlink merge --strict. The multi-repo path was correct and ungated: with a high-severity exposure uncovered across the estate, merge exited 0, and --help matched nothing on strict|fail|exit|gate. So the correct cross-repo answer and the thing a pipeline can fail on were different commands. --strict exits 1 on unmitigated exposures, on reproduced @confirmed exploits (counted separately, because @accepts silences one nowhere else in the product and an estate can read 0 unmitigated with verified exploits in it), and on a repository whose report could not be read — an estate answered in part. Opt-in, for the same reason guardlink ci --strict is: a workspace mid-annotation has unmitigated exposures by construction. The merged JSON gains totals.confirmed.

  • guardlink ci --severity and --scope. --strict on a real repository was red on day one and green never — 111 exposures and 52 drifts on NodeGoat — which is how a gate gets deleted rather than adopted. --severity critical,high narrows risk findings (unmitigated exposures, confirmed exploits); it deliberately does not narrow drift, parse errors, stale claims or unqualified acceptances, which are integrity findings that carry no severity. An exposure written without a severity always counts, and a misspelled level is an error rather than a filter that matches nothing and exits 0. --scope services/api narrows everything, matching on path segments. Both echo what they dropped, in the text output and in summary.filters.

  • guardlink review is scriptable (R9). --accept <id>, --remediate <id>, --skip <id> with --by, --justification and --until, plus --from <file> for a JSON batch, mirroring guardlink entitle. --list --format json emits the ids a batch needs. A batch is refused whole rather than skipping a row it could not read. A non-TTY invocation with no scripted flags now fails loudly instead of exiting 0 having silently done nothing.

  • annotation_hash covers every published artifact (R10). The staleness mechanism existed and covered the 19 artifacts that mattered least. model.json now carries a provenance block, findings.sarif carries runs[0].properties.annotation_hash, guardlink parse -o stamps metadata, and guardlink validate --artifacts checks all of them plus MANIFEST.json, report.json and the eight agent instruction files. Absence is not drift, and an unstamped optional artifact is not drift; a wrong hash is. readArtifactHash reads the mermaid %% header, four JSON shapes and the synced markdown freshness line.

  • guardlink.ci/v1 gains confirmed, unqualified_acceptances, summary.confirmed, summary.acceptances, summary.unqualified_acceptances and summary.filters. Every field the schema already carried is present and unrenamed.

  • The hypothesis ledger — guardlink hypothesis. An @exposes is a hypothesis and @confirmed is one with evidence; the third state, tested and not exploitable, had nowhere to go. .guardlink/hypotheses.json (guardlink.hypotheses/v1) records what happened when an exposure was tested, keyed by claim key, with the evidence, who, when, and the code hash beneath the claim.

    • hypothesis refute <file:line> --evidence … and hypothesis confirm <file:line> --evidence … [--write] record an outcome; evidence is required, and a confirmation is held to the gate's evidence bar. --write inserts the @confirmed line beneath the @exposes. hypothesis confirm --from-scan <report.json> joins cxg findings to claims (location, then asset and threat, then CWE), records the confirmed ones with redacted evidence, and lists the ambiguous and unmatched.
    • Outcomes expire with the code: when the claim's anchor hash changes, a refutation lapses to untested (previous outcome attached) and a confirmation becomes retest. History is kept.
    • hypothesis list [--state] [--json] shows every exposure's state; hypothesis next [--intake] ranks what to test (retests first, then severity, undefended path, unowned) and can print a brief for bugb intake.
    • guardlink status gains a Hypotheses: line; the dashboard stops counting a refuted exposure as open and shows the evidence in the drawer; threat reports see hypothesis per exposure; guardlink lint treats a refuted exposure as paired. The field is invisible to the annotation hash and stripped from model.json.
    • Library: readHypotheses, classifyHypotheses, attachHypotheses, rankUntested, recordOutcome, importScan, confirmedLine, writeConfirmedLine from guardlink/hypothesis.
  • Dashboard upgrade — it directs, not just reports. The Executive Summary opens with the grade (now counting only open exposures), five numbers that each link to the view behind them, and a computed What to do next list: confirmed findings, open critical/high exposures, stale claims (guardlink verify --stale), a first verify when no ledger exists, open exposures introduced with AI help, inert entitlements, audits awaiting review, and file coverage — each with a link into the filtered page and a copyable command.

    • The URL hash is the page and its filters (#threats?sev=critical,high&status=open), so back works and a view can be shared. Every table sorts by clicking a header; a search box (/) filters the current page; severity, status and claim-state chips narrow the Threats page; an identity chip narrows Attribution.
    • When .git/config names a GitHub, GitLab or Bitbucket origin, every file:line links to the file at HEAD and every sha to its commit (no subprocess; links at HEAD so a committed dashboard does not churn). Without one the text stays, with a copy button.
    • With a ledger, each claim row shows verified / stale / unverified. The drawer gains actions — open on the host, copy the guardlink verify or guardlink blame command — and previous/next through the filtered list; Escape closes it.
    • Attribution adds a quarterly trend of exposures introduced (human vs AI-assisted) and fixed, a human-versus-AI cohort card with exposures per 100 commits from a one-call history walk, per-identity risk score and oldest-open age, the files most rewritten under open exposures, and a click-through from any person or model to their claims. Data flows moved to Data & Boundaries with jump links.
    • Same palette; tighter type and spacing, sticky headers, real empty states, light theme at parity, a print stylesheet. src/dashboard/generate.ts is now a composition root over pages/*, client.ts, styles.ts, html.ts, links.ts. Library: computeActions, computeLedgerStates from guardlink/dashboard's data module; listCommits and the extended summarise(entries, { commits, as_of }) from guardlink/blame.
    • Analytics page. An asset × threat heatmap (red while a pair has an open exposure, green when every one is mitigated, blue when accepted; darker is more), severity × status, threats by frequency, control coverage with the controls nothing uses, and the files with the most open exposures. With --blame: people × quarter and AI tool × severity. Every cell links into the filtered Threats page. Library: computeAssetThreatMatrix, computeControlCoverage, computeSeverityStatus, computeAssetDetails, computeIntroductionHeat, computeToolSeverity.
    • Tables are fixed-layout with a clamped description, the asset over its threat in one column, and file:line over its directory; long tables paginate at 25 rows (50, 100, all) and the pager follows sort and filters. Search matches every word, so #api sqli is one pair. The asset drawer now shows open/mitigated/accepted/confirmed, open by severity, threats with bars, controls, data flows in and out, classifications, boundaries, owners, lifecycle counts, claim states, who introduced its exposures and with which tools, the oldest open age, and its files — each a link into the filtered view. One tile per asset whether annotations name it by #id or by dotted path. Threat Reports gets copy-report, download-as-markdown and copyable commands for every framework; the diagram toolbar is a segmented zoom group plus copy-source, and diagrams open fitted to the panel.
    • Type, colour, icons. Helvetica Neue for text and Monoska for code (JetBrains Mono from Google Fonts as the fallback; Inter is gone). The palette is the brand's ten colours with tints mixed from them, and both themes were re-tuned for contrast: muted text, links and badges all read on their surfaces, the light theme uses blue as its accent and keeps green for "covered", critical badges are filled and high ones outlined so the two are told apart. No emoji anywhere on the page: inline SVG icons for navigation, section heads, tiles, buttons and the theme toggle, and the diagrams draw threats as hexagons, controls as pills, stores as cylinders and status as shape instead of glyphs (the committed .mmd artifacts keep their emoji, which is all GitHub can render; generateThreatGraph and friends take { icons: 'none' }). Matrices fill their panel; the drawer is wider.
    • Annotation drawer. Clicking an annotation on the Code page now shows its fields (asset, threat, control, severity, source, target, mechanism, classification, owner, actor, …), references, the claim it makes with its coverage status and ledger state, attribution, the asset it is about, the raw annotation with copy, the code around it, and actions: open in Threats, asset details, open on the host, copy the verify command.
    • Verified means locked. Each claim carries who locked it in the ledger and when; the drawer shows it, and when nothing is stale or unverified the Verified-claims tile says all locked <date> by <who> so 100% reads as one guardlink verify run, not a review.
    • What changed. guardlink dashboard --since <ref> (tag, branch, commit) parses the model at that ref, diffs it against the one rendered, and puts a strip under the summary KPIs: new exposures (and how many are still open), resolved, newly confirmed, new mitigations, and verified claims gone stale in files that changed since — each a link into the rows behind it. New claims carry a new badge and a New since <ref> chip. Library: loadSince, computeChanges, newClaimKeys; generateDashboardHTML(model, root, analyses, { since }).
    • Who owns the risk. An Analytics panel rolls open exposures up to the team named by @owns (assets, open, confirmed, worst, stale, oldest open) and lists the exposed assets no team owns; the summary's what-to-do list gains "N exposed assets have no owner". Rows carry data-owner and the Threats page filters by owner=. Library: computeOwnership, buildAssetIndex.
    • Sensitive data under open exposure. An Analytics panel per data classification: assets recorded with @handles, how many carry an open exposure, open and confirmed counts, worst severity, and the exposed assets as pills. Rows carry data-handles; the Threats page filters by handles=. Library: computeSensitiveData.
    • The feature dropdown no longer lies. Analytics is rendered once per feature and the dropdown swaps the matching grids in; Diagrams and Attribution, whose numbers stay whole-model, say so and offer the --feature command to copy.
    • Diagrams. A focus select on the threat graph shows one exposed asset with its threats, controls and neighbours (one focused graph per asset, via selectSubgraph); a find box dims everything that does not match; the fit never shrinks below 60% so labels stay legible; the light theme gets light node fills.
    • Reports and Code. Asset, threat and control ids in a rendered report link into the Threats table. File cards carry their open, confirmed and stale counts and the worst open severity, and the riskiest file is listed first. Library: computeFileRisk.
  • Attribution — guardlink blame. Who introduced the code beneath each claim, who declared it, who declared its fix, and which AI tool co-authored those commits — read from git at run time (blame, log -L, Co-Authored-By / Assisted-by trailers). Nothing is written to the repository and no GAL syntax was added: attribution is a fact history holds, not a claim a person makes.

    • guardlink blame [dir] [--file f] [--json] [--identity name|email|hash] prints a group per file and two summaries (per person, per AI tool+model: introduced, fixed, open, median days to fix). JSON is guardlink.blame/v1, keyed with the ledger claim key so it joins to .guardlink/verified.json. Always exits 0 once the model parsed; outside a git checkout every entry is no-git.
    • --blame on parse, status, report and dashboard attaches the same data: a blame field on exposures, confirmed findings and mitigations, an Attribution section in the report, an Attribution page in the dashboard. Without the flag every output is byte-identical to before. The field is invisible to the annotation hash and stripped from .guardlink/model.json, like anchor.
    • AI attribution is declared, never detected. Shipped rules recognise Claude Code (with the model), GitHub Copilot's coding agent (bot author; the human co-author becomes the author), Codex CLI, Cursor, Gemini CLI, aider, Warp, and the kernel-style Assisted-by: AGENT:MODEL. A commit with no trailer stays human; a Co-authored-by that matches no rule is a human co-author. Rules are extended in .guardlink/config.json under blame.tools.
    • blame.identity in config (name default, email, hash) decides what a person is shown as everywhere; hash is the setting for a dashboard that gets committed. blame.ignore_revs feeds git blame --ignore-revs-file.
    • Honest degradation: file-header claims report granularity: file and the commit that added the file; a file with uncommitted edits, or a shallow clone, marks every introduction lower_bound; uncommitted, no-anchor, file-missing and error are per record.
    • MCP: guardlink_blame(root, file?). TUI: /blame [file]. Both are non-mutating, so the cached model never carries attribution unasked. Library: computeBlame, attachBlame, buildBlamePayload, formatBlameText, readBlameConfig from guardlink/blame.
  • Stale claim detection — the first half. A claim in a comment was never re-checked: remove the control beneath a @mitigates and the model kept reporting the exposure as covered. GuardLink now resolves every annotation to the declaration it sits on (tree-sitter, every language in the default include list), hashes that declaration's non-comment tokens, and records the hash in a committed ledger, .guardlink/verified.json, when a person or agent verifies the claim.

    • guardlink verify [dir] [file[:line]…] [-f text|json] [--stale] [--all] [--dry-run] [--by <name>] [--force] writes the ledger and nothing else. The default form locks unverified claims and prunes orphans; re-locking a stale claim takes --stale, --all, or a named target, because that is an assertion that the control still holds. The verifier is recorded as human:<git user.name>. JSON output carries the schema id guardlink.verify/v1.
    • guardlink ci gains a third check. Stale claims are listed, mitigations and acceptances first. Advisory by default; --strict exits 1 on a stale mitigation or acceptance and never on an unverified claim. JSON under guardlink.ci/v1 gains stale, unverified, orphans and matching summary counts, additively.
    • guardlink status adds one line: verified, stale and unverified counts.
    • guardlink validate emits ledger-corrupt when the ledger exists and does not parse.
    • Library: classifyClaims, demotionSet, relationRecords, readLedger, writeLedger, planVerification, applyVerification from guardlink/parser; parseStructure from the new guardlink/structure subpath. SourceLocation gains an optional anchor. ParseProjectOptions.anchors (default true) skips the structure pass. Every location object now carries anchor; consumers that deep-compare locations should compare the fields they mean.
    • guardlink verify refuses to write when a target matches no claim, when the directory it is given carries no .guardlink/, and when --stale or a file target is asked of a ledger that is corrupt or was written at another anchor hash version; a whole-repository run rebuilds it.
    • .guardlink/model.json does not carry anchors. They are hashes of code the annotation hash cannot see, so writing them would make every regeneration a large diff no drift check explains; the ledger is where they are recorded.
    • A claim whose grammar failed to load on this machine classifies as unverified, never stale, so a packaging defect cannot fail a --strict build.

    Package size grows by about 25 MB of grammar WASM, fetched at build time from pinned npm packages. Swift, Kotlin and Dart resolve to file scope until a compatible WASM is placed by hand: the first two ship none, and Dart's only published WASM predates the runtime's linking format. Demotion (a stale mitigation counting as unmitigated), SARIF and report changes, the MCP guardlink_verify tool and the template changes follow in the second half. Design: docs/superpowers/specs/2026-09-03-stale-claim-detection-design.md.

Changed

The two entries that lead this section are the behaviour change described at the top of this release. They are stated in full here.

  • Doc comments are read, and the forms that are not read say so. stripCommentPrefix removed exactly one comment marker and parseLine then required the very next character to be @. Every doc-comment convention is a marker plus one character, so every one of them landed one character out of reach and was dropped — with no diagnostic, validate green, and the claim in no threat model. Measured on a 2,400-file repository: 54 real annotations invisible this way, among them 32 @mitigates, 6 @exposes and 7 @boundary. GuardLink's own generated guidance told authors to annotate "in the doc-block of the function or module they describe", which for Rust is /// — the exact form it then discarded.

    • Now parsed (SPEC §2.9.1): ///, //!, //!<, ///< (Rust doc and inner doc, Doxygen, C#, Swift, Dart); /**, /*!, /**< on the opening line (JS/TS/Java — the * @… continuations already worked); ## (Python, Bash, YAML banners); ;;, ;;; (Lisp, Clojure); %% (Erlang module comments); --- (Lua LDoc); -- |, -- ^ (Haskell Haddock); ''' (VB.NET XML doc). The rule is general — a marker repeated, or decorated with one of !<|^, is still that language's comment — so the next language does not need a code change. The set of comment openers is unchanged: only what is consumed after a recognised opener grew, so nothing that was code became a comment.
    • New diagnostic unrecognised-comment-form (warning): a comment whose marker the parser could not fully account for, with a known verb behind the residue. New diagnostic uncommented-annotation (warning): a known verb with annotation structure on a line carrying no comment marker — what an annotation written inside a Python docstring or a Ruby =begin block looks like. Both are scoped to the 25 known verbs, and that scoping is the safety argument: comment lines beginning @token number 750 on juice-shop, 2,330 on ghostfolio and 3,176 on bkeeper, of which 0, 0 and 3 are a known verb. Measured after the change: zero new annotations and zero new diagnostics on all three.
    • guardlink ci gained a parse-diagnostic check — its first. It reported three things about a model and nothing about whether the model was read, so a repository could be told it had zero unmitigated exposures because six @exposes lines were written in a form the parser drops. Reported first, because it qualifies every count under it. Advisory as ever; parse errors join the --strict predicate, warnings never gate. guardlink.ci/v1 gains parse (the diagnostics as the parser produced them) and summary.parse_errors / parse_warnings / parse_by_code.
    • init guidance now shows the host language's real doc-block — /// for Rust, C# and Swift, // for Go, a JSDoc block for TS/JS/Java/Kotlin, # for Python, Ruby and Terraform — and, for Python and Ruby, says explicitly that docstrings and =begin blocks are not read.
  • An annotation in 37 of 43 supported languages was read by nothing and reported by nothing. stripCommentPrefix recognises every comment style SPEC §2.9 tabulates — //, #, --, %, ;, REM, ', /* */, (* *), {- -}, <!-- --> — and the scan glob listed the extensions of six of the languages that write them. So the marker was stripped correctly and the file was never handed to the stripper. Measured on a probe repository carrying one annotated file per language §2.9 names: an @exposes, correctly formed and correctly commented, was read in 6 of 43 extensions. .php, .pyi, .kts, .erl, .clj, .vb, .ml, .pl, .tex and 28 more were invisible.

    The loss was silent, which is what made it expensive. uncommented-annotation never fired, because the line is a comment; unrecognised-comment-form never fired, because the marker does parse. §2.12's whole point is that a claim which reaches no threat model produces a warning, and both of its diagnostics are asked of a file's lines — so neither can speak about a file that was never opened. guardlink status counted the repository as having no annotations, validate exited 0, and the author's @exposes sat in the file looking exactly right.

    This is the parser half of why bravos annotate could not model a PHP, Kotlin or Objective-C repository: the agent's analysis was sound and its @exposes landed in the file, and the model stayed empty. (The revert gate that deletes such an edit before it lands is in bravos, not here.)

    • One list, so a language cannot be half-added (src/parser/languages.ts). Four places used to answer "which files does GuardLink read" — the parser's scan glob, clear's scan glob, the MCP context layer's extension set, and commentStyleForExt's marker table — and they had drifted from each other and from §2.9. They now derive from a single extension → marker registry. The drift was load-bearing: clear swept fewer languages than parse read, so it left annotations behind in exactly the files whose annotations are hardest to find by hand, and guardlink_context answered not_scanned for a .php file, which tells an agent "the parser never read this" about a file that now parses.
    • The marker table was wrong where it was not missing. It listed .ada, an extension Ada does not use (.adb/.ads), and had no entry for .php or .pyi. That table is what guardlink review and guardlink migrate fall back to when there is no neighbouring comment whose style they can copy, so a wrong answer writes a line into the user's source that does not compile.
    • .m is resolved to Objective-C. It is the one genuinely ambiguous extension in circulation — Objective-C //, MATLAB and Mercury %. Reading does not have to choose and does not: every recognised opener is tried against every line, so a MATLAB % annotation still parses. Only the write-side default picks a side, and only when there is no comment beside the insertion point.
    • SPEC §2.9 now carries the extension column, and says why it is normative: a style is only supported in the files a parser actually opens, and a parser must be able to name the extensions it reads.
    • Widening which files are opened did not widen what counts as a comment. No marker was added; every style was already recognised. tests/scanned-languages.test.ts pins both directions — an annotation in each of the 70 extensions §2.9 names is read, and a bare @exposes in a newly-scanned .php file is still not an annotation (it gets uncommented-annotation, which now reaches PHP), a verb quoted inside a string still produces no annotation and no warning, and a Python docstring annotation is still unread per §2.9.3 and still reported. The test's source of truth is §2.9's table transcribed, not the new registry, so it would have failed throughout the bug: against the old glob it reports 39 unread languages.
    • Structure-layer mapping extended to every newly scanned extension, most to null — file scope with reason no-grammar, which is a real answer. undefined is the one that is a bug, and the test now forbids it.
    • Measured end to end on a repository with one annotated file per extension the release-readiness investigation named (.cs .kt .kts .php .h .pyi .tf .sql): relationship annotations read from source went from 11 across 5 files to 18 across 8.
  • formatQueue and formatIntake require the queue total. Breaking for guardlink/hypothesis. Both are re-exported from src/hypothesis/index.ts and published as the guardlink/hypothesis subpath, and both gained a required trailing parameter: formatQueue(q, total) and formatIntake(q, project, total), where total is the queue length before -n bounded it — see the bounded-queue entry under Added.

    What you observe, in TypeScript: error TS2554: Expected 2 arguments, but got 1. on formatQueue(queue), and Expected 3 arguments, but got 2. on formatIntake(queue, project). Add the total. A caller that already has the whole queue and is not paging passes queue.length.

    What you observe, in untyped JavaScript: nothing throws. total is undefined, q.length < undefined is false, and the table header renders undefined to test — so the one line that says how much there is to test is wrong, printed with no diagnostic. This is the case to check for if you consume the subpath from JS.

    total decides the empty page too. formatQueue's Nothing to test: every exposure has an outcome that still holds. is now owed to total === 0 rather than to an empty q. It is the one line in that renderer that names a state instead of counting, and an empty page of a non-empty queue is not that state: formatQueue([], 142) renders the ordinary header, 0 of 142 to test. Unreachable from the CLI — -n clamps to at least 1, so the page is empty only when the queue is — and reachable from the subpath by any caller that pages.

    Why it is required rather than defaulted. A = q.length default hands a caller who forgets the argument the permissive answer — nothing truncated, and a 10-of-142 page reporting 10 to test. That is the exact wrong number these renderers were changed to stop, reinstalled as a silent default. Required, the omission is a build failure instead of a confident wrong answer.

  • ARTIFACT_SCHEMA_VERSION is 2. Every .mmd entry in MANIFEST.json now carries renderable and a render measurement. annotation_hash is unchanged and in the same place, so an existing validate --artifacts reads the new manifest exactly as it read the old one.

  • ANNOTATION_HASH_VERSION is 3, adding an acceptance's accepted_by and expires. Without them, re-signing an acceptance or pushing its expiry out by a year moved nothing the staleness gate could see, and those two fields are the entire difference between a decision and a deletion. Every committed artifact reads stale once; regenerate with guardlink artifacts . and guardlink sync.

  • SPEC §6.1's SARIF invariant is stated precisely rather than by proxy. It required byte-identical SARIF documents for a model with and without entitlements; it now requires byte-identical runs[].results and runs[].tool. runs[].properties.annotation_hash does move, and must: an @entitles is an annotation, and a provenance hash blind to one would report a rewritten model as unchanged — the silent all-clear §2 exists to prevent, and the reason the annotation hash was taught about entitlements in v2. The invariant that matters is the one the mapping table states literally: no result, no suppression, no property on any result.

  • SPEC §2.9 no longer promises what no implementation has ever done. \"\"\" \"\"\" is withdrawn from the supported styles and moved, with =begin/=end and the multi-line forms of <!-- --> and {- -}, to a new §2.9.3 "Not read". The parser is line-oriented with no block state, and a Python docstring is a string expression rather than a comment; reading string literals as comments would make any multi-line string in any language a place annotations can hide, including the template literals tools use to document this syntax. §2.9.3 requires a conforming parser to name those forms instead, which is what the two new diagnostics do. New §2.9.1 specifies repeated and decorated markers; §2.12 gains a note that its tiering only ever sees lines that reached it.

Fixed
  • A scan result can no longer be recorded against a claim the probe never tested. guardlink sarif stamps each result with guardlink/threatId, derived from (asset, threat, file) with no line in it. Two @exposes in one file naming the same asset and the same threat therefore mint one id. Delete the first and let the second come to sit on the line the first was tested at, and the surviving claim matches every stamped value there is — file, line, asset, threat, id — so guardlink hypothesis confirm --from-scan joined the finding to it and wrote a confirmation, with nothing anywhere looking wrong. No care taken by the consumer could detect this: the discriminator did not exist to send.

    • Each @exposes result now carries its claim key, as a second SARIF-native keyed fingerprint guardlink/claimKey, mirrored to properties.claimKey exactly as threatId already is. The key was already there — it is the one the hypothesis ledger keys an entry on (src/parser/claim-key.ts) — and it is a digest of the claim's own words: verb, identity arguments, external refs, description, plus the file. The join wanted claim identity, not code identity, and this is the identity the ledger already uses to say which claim an outcome belongs to.
    • --from-scan RESOLVES on the key, before anything coarser runs. A finding carrying the claim key is looked up across the whole model. A key matches at most one claim, so that lookup is the answer; a key naming no claim is reported as stale, with nothing written, no by-hand target offered (any claim standing there now is a different claim) and a non-zero exit. Only an unstamped finding falls to the coarse joins — location, then asset and threat, then CWE — which is exactly as it behaved before.
    • The accepted wire format is one shared definition — names and surfaces — not a list on each side. An unrecognised stamp is worse than an absent one: it takes the coarse join on a report that did carry the discriminator. --from-scan accepts the key under guardlink/claimKey, claimKey or claim_key, and from either level — the finding's top level or its annotation object — spread onto that level or left as a nested properties or partialFingerprints map, which is what copying a SARIF result member wholesale gives you. CLAIM_KEY_NAMES and CLAIM_KEY_SURFACES in src/parser/claim-key.ts are the single definition the exporter emits through, the reader generates its accepted placements from, and the operator-facing hint interpolates — so adding a surface widens the reader and the test matrix with no edit to either, and nothing the product advertises can be something the reader refuses.
    • Consolidating one dimension relocates the drift; it does not reduce it. This gap was found four times in this change and each time it had moved to whichever dimension was still written twice — first the name casing, then the SARIF fingerprint name, then the nested container. Sharing the names left the container list hand-written in the reader, and the very next shape a conforming consumer produces (properties forwarded as a nested map) was refused while four documents promised it worked. The rule this records: when consolidating to stop two sides diverging, enumerate every dimension along which they can disagree and derive all of them from one definition. The two precedence orders stay separate for the same reason in reverse — deriving the name order from the surface order satisfied whichever half it matched and silently inverted the other; each now keeps the earliest-accepted entry in front, and a report carrying a key in both surfaces still resolves to the partialFingerprints one, pinned by test.
    • A stamp is only ours if it is shaped like one, and a stamp that is not gets its own state. The accepted names include free-form bags another tool may also write a claim_key into, and any non-empty string was being taken as the key: a report carrying properties.claim_key: "pending" had its finding diverted to stale — told its claim had been "deleted, or its asset, threat, refs, description or file edited", which was never true — destroying a confirmation the location tier would have made. A value now counts as the stamp only if it matches CLAIM_KEY_PATTERN, which lives beside the ${base}:${ordinal} that mints it so the check cannot drift from the thing it checks. Three situations stay three: no stamp (the coarse joins apply), a well-formed key naming no claim (stale), and a value that is not a key (malformed — reported with the value and the field it arrived in, never joined, because falling back would confirm on what was just rejected and folding it into "unstamped" hides that a producer is emitting something else under a guardlink name). Malformed exits non-zero alongside ambiguous, stale and unmatched.
    • Validate every candidate, then select among the valid ones. The shape check was specified without specifying where it sat in the sequence, and the sequence was the whole of it: the reader took the first non-empty value and judged that, so a foreign claim_key placeholder sorting ahead of our own emitted claimKey — in the same object, from the same properties bag forwarded wholesale — buried a valid key and the finding was refused. A validity check placed after a selection step validates the wrong candidate. Every candidate is now collected across the names × containers matrix in precedence order and partitioned afterwards; the key is the first valid one, so the precedence tests keep their meaning, and unshaped values found beside it are reported as a note that changes neither the join nor the exit status — selecting the good key must not silently hide that something is writing garbage under a guardlink name.
    • The description written into source is treated as external text, by construction. scanEvidence interpolates five scan-report-controlled values, and only request and response were being collapsed; confirmedLine escaped quotes and backslashes but not newlines. A title carrying a newline therefore ended the annotation and placed report-controlled text on the next line of someone's doc-block — measured on the real write path, the @confirmed then parsed back as nothing at all while the CLI printed wrote src/a.ts:5. Rather than patching the fields that were found, every value now passes through oneLine at the one boundary it can enter through, so a field added later cannot skip the treatment, and the finished line is re-parsed inside writeConfirmedLine — not in its caller — and refused unless it reads back as exactly one @confirmed. Both halves follow the precedent src/review/index.ts already sets for annotation writes, reusing its oneLine/escapeDesc rather than minting a third copy.
    • Sanitisation covers every grammar the text passes through, not only ours. Collapsing newlines and escaping quotes neutralises GuardLink's grammar and leaves the host language's comment syntax untouched, so the sequence that closes a block comment — ordinary in a probe's response when it echoes CSS or JS — terminated the customer's doc-block and put report-controlled text in code position in their file. Neither the collapse nor the re-parse could catch it: the re-parse reads the bare annotation, outside the comment it is about to be spliced into. The closer is now broken as well, and the form it belongs to is derived from the source line being written into — not from the file extension, which is a proxy the parser does not share (it accepts a C-family block comment in any file with no language gate). The line's own opener settles it; a line-comment marker means no closer at all; a continuation line is resolved by looking back for the block that opened it; and an extension table is only the last resort for a line that settles nothing. That one derivation also answers the second question the same information decides: an @exposes that closes its own comment has its terminator reproduced on the inserted line, without which the host file was left inside an unterminated comment and everything below it silently left the compile. Line comments need no closer of their own — but because of the collapse, not the absence of a terminator; see the next bullet. Pinned by asking the structural parser whether the host file still means what it should — a test written with the re-parse's own blind spot would pass just as uselessly.
    • The collapse covers every line terminator a host grammar recognises, not the ASCII two. A line comment needs no closer broken — it ends at a line terminator — but ECMAScript ends one at any of them, and the collapse was [\r\n\t]. A single U+2028 between two non-whitespace characters matched neither that class nor the \s{2,} pass that follows it (and U+0085 is not in \s at all), so it went into a //-commented host verbatim, ended the comment, and left the rest of the report's evidence as source — measured on the real write path, the structural parser resolved the inserted text to a new declaration. The collapse is now the Unicode mandatory line breaks (LF, CR, NEL, LS, PS) plus VT and FF, and it is one definition: entitlements.ts kept a second copy for fear of an import cycle that does not exist, so it now re-exports the first — a character set written twice is the shape that drifts, and this is the same class as the block closer one character further out. It covers the @accepts and entitlement writers too, where the text is a human's justification rather than a scanner's.
    • Two sibling confirmations are both writable. writeConfirmedLine's duplicate guard scanned a fixed six-line window for @confirmed <threat> on <asset> — and a written @confirmed carries only that coarse pair, the very tuple GAP-58 is about, so two @exposes differing only in description were indistinguishable to it by construction. This was the last place in the write path still keying on the coarse pair, and one of any such sibling pair could not be written by any supported path, including the by-hand command the CLI offers. (Not a consequence of the descending write order: ascending failed too, with a different message.) The arbitrary window is replaced by semantic bounds — stop at the next @exposes, because a @confirmed past it belongs to that claim, and stop on leaving the comment block — which keeps the defensive scan instead of buying the fix by giving up detection. That a written confirmation carries no claim-specific identity at all is real and remains open; it is not addressed here.
    • --write no longer puts an unverified @confirmed into source. This was the one surface the labelling invariant had been left off, and it is the highest-consequence one: an @confirmed in a repository is a claim that the exposure was tested and proven — read by later scans, by reviewers and by guardlink sarif — and unlike a ledger entry it never expires. A coarse-joined confirmation may be about a different exposure than the probe tested, which is precisely the false @confirmed GAP-58 names and which has happened once already. --from-scan --write now inserts only key-verified confirmations, prints each one it skipped with why and the by-hand command, and states in the line it does write that the scan stamped that claim's own key — so the rule survives the code that enforces it rather than living only in a caller. Location- and CWE-joined confirmations stop being written back; that cost is the point, because those are the joins that can be wrong. The outcome is still recorded in the ledger, and the manual confirm <file:line> --evidence … --write path renders exactly as before: human evidence about a named target, with no join to qualify.
    • The by-hand command offered for a withheld confirmation no longer names a line this same run moved. The ! skipped block printed record.line from the pre-write model and ran before the write loop, so a withheld claim below a key-verified write in the same file had already shifted by the time the operator ran the command they were handed. With consecutive @exposes the shifted line holds a different claim — resolveTarget finds exactly one @exposes there, every guard passes, and the operator records a confirmation against an exposure the probe never tested. That is GAP-58 one layer out: the false @confirmed moved from our write path into a human's fingers, on our instruction, and it is not safer for our not having typed it. The block now reports after the writes, at the line each withheld claim occupies once they have landed, and derives it from the one calculation the wrote lines already use — an insertion at or above a pre-write position pushes it down by one — because two sites computing where a line is will disagree silently. Message text, skip reasons and the stdout/stderr split are unchanged; only the line and the order of printing move. What this teaches, recorded because the code had it written down already: the annotation one block below this loop explains at length that record.line is a pre-write coordinate and that insertions shift later targets, and the loop above it used raw pre-write lines anyway. Documenting a hazard is not guarding against it. formatImport's ambiguous suggestions are the remaining position-dependent commands — it runs before any write and cannot know what will land — and are annotated as GAP-77, which is the general residue: hypothesis confirm addresses a claim only as file:line, so every handed-out target is correct at the moment of printing and no longer. Pinned by a test that takes the command out of the CLI's own output and runs it, asserting the confirmation attaches to the withheld claim and not to its neighbour.
    • And it no longer exits non-zero on a run that succeeded. An exit code is a claim about what happened; it has to mean one thing and be true in both directions. Made honest about under-writing, it became careless about success: two findings carrying the same claim key — the ordinary shape when two cxg templates probe one exposure — resolved to one claim and folded into one ledger entry, but writes kept both, so the second insertion was refused and a run whose source ended up exactly as intended exited 1 with an alarming ! line. A re-import of the same report did the same, since the key digests the claim's words and is unmoved by the line already inserted. Both halves are fixed: writes is de-duplicated by record.key — one claim, one write, keeping the last occurrence because upsert replaces the entry object on every call and only the final one is the entry the ledger holds — and writeConfirmedLine now reports an existing confirmation as a typed outcome (already-present) that the caller reads by type. Deliberately not a string match on the error text: a control-flow decision keyed on an English sentence is a defect waiting for a reword, and this branch has corrected that exact shape repeatedly. Every other refusal stays a throw and still exits non-zero. The rule above is untouched and was not widened by this — a confirmation that is in the source is success, a confirmation that is not, because we withheld it, is not. All five cases are pinned: duplicate keys, re-import, withheld-only, mixed, and a genuine write failure, that last one to prove the classification did not simply swallow every error.
    • A write that did not fully happen no longer exits 0. Withholding a coarse-joined confirmation is right; reporting it as success is not. The reader of this exit code is bravos, which orchestrates the loop and never sees the ! skipped lines on stderr, so a 0 told it the corpus now carries confirmations that are not in it. The exit is non-zero whenever --write withheld any confirmation, partially written included — not only the all-withheld case, because exiting non-zero for "nothing written" while half-written stayed 0 gives one code two meanings, and partial success indistinguishable from complete success is the worse of the two for an automated consumer. This is the rule already stated above the exit check — "a scan whose findings were not all recorded exits non-zero" — applied to the write outcome as well as the join outcome; the skip messages and the banner a human reads are unchanged. The withheld set is computed once and used for both the messages and the exit, so the two cannot disagree. Both cases are pinned: an all-location-joined report writes nothing and exits non-zero, and a mixed report writes the key-verified confirmation, leaves the other in the ledger only, and still exits non-zero.
    • Both comment delimiters are broken, not only the closer. Neutralising the closer left the other end open, and block comments nest in Rust, Swift, Kotlin, Scala, Dart, Haskell and OCaml — every one of them already in the closer table, so the code claimed to cover them. A probe echoing C, JS or CSS source returns /* as ordinarily as it returns */; spliced verbatim into a .rs doc-block it opens a nested comment, the block's own */ on the next line closes only that nested level, and the outer comment runs on past the declaration it documents — which, with everything below it, silently leaves the compile while GuardLink still parses the annotation back perfectly. The same hole the closer pass exists to close, entered from the other end, and the writer's re-parse is blind to it for the same reason: it reads the bare annotation, outside the comment it lands in. Both ends now come off the same BLOCK_FORMS pair, commentFormAt carries them together, and the two passes that need them (the offer-time one keyed on the extension, the authoritative one derived from the source line) call one transform. Deliberately no table of which grammars nest: an injected opener is inert where they do not nest and fatal where they do, so breaking it always costs nothing and sometimes saves the file, and one more per-language dimension modelled in one place and relied on in another is the shape this branch has now corrected repeatedly. Pinned structurally in a nesting language — scan-controlled text carrying the opener, written through the real CLI path into a Rust file, asserting the host file still means what it did (the @confirmed still anchors to find_user), not that our own annotation re-reads.
    • The comment prefix is derived, not copied — so it cannot re-inject the opener. Sanitising the annotation text and then splicing an unsanitised prefix in front of it left the same nesting failure reachable with no report-controlled text at all: an ordinary /** @exposes … opening line has an opener for its prefix, so the inserted @confirmed carried /** and opened a second comment, the block's own */ closed only that inner one, and in Rust, Swift, Kotlin, Scala, Dart, Haskell or OCaml the declaration the doc-block documents — with everything below it — left the compile while GuardLink re-parsed the annotation perfectly. The fix is not to sanitise the prefix but to stop copying it: what is being inserted is a continuation of an existing comment, so the prefix is that form's continuation marker, derived from the same commentFormAt that already answers every other host-grammar question here, with the opening line's indentation preserved so the inserted line still sits under its @exposes. Reproducing the closer instead was rejected: balanced inside a nesting host, it ends the outer block early in a non-nesting one. A form with no continuation marker a reader could strip refuses the write rather than guessing — unreachable in practice, because such an opener line is not a comment to the parser and never reaches the model, which is why the marker lives beside the opener and closer in the one form table rather than in a second list. The lesson recorded: the treatment belongs to the whole line that is written, prefix, terminator and indentation included, not to the component that is obviously external. Pinned structurally in Rust with no scan-controlled text — the block still terminates where it did, and the declaration below it is still a declaration.
    • Every coordinate this command hands a user is now the one it claims to be. The withheld block was routed post-write; its sibling was not. formatImport runs before the writes, so the by-hand commands it printed for an ambiguous finding named pre-write lines that the same run then moved — and with consecutive @exposes the shifted line holds a different claim, so an operator following our own instruction records a confirmation against an exposure the probe never tested. Those candidates now live beside the withheld ones in the CLI, after the writes, through the one afterWrites calculation (hoisted so it is the identity when nothing was written), and stay on stdout where they were. The (file:line) in each outcome header is dropped rather than labelled: the authoritative positions are the wrote and already confirmed lines, and printing a second coordinate for one thing asks the reader to hold two and pick correctly — the same lesson already applied to names, containers and comment grammars, on the axis of positions. formatImport now emits no model coordinate at all; the only file:line left in it is the scan report's own, which is correct as the report's claim about what it tested.
    • The --from-scan help interpolates the accepted names and containers. It was the last operator-facing string still spelling them out by hand while the banner beside it interpolated. Drift is benign when the definition widens (the help under-advertises) and harmful when it narrows: the help then advertises a name the reader refuses, which is the one thing the shared definition exists to prevent. Pinned the way the banner is — the test takes the names and containers out of the generated --help, forwards the key under each, and requires the real reader to resolve it on the GAP-58 shape where every coarse tier would answer wrongly. Both of these were third instances on axes believed closed, because the previous sweeps were scoped to the file being edited rather than to what the command reaches. This one was scoped by reachability across every file: positions — resolveTarget's error echoes the user's own argument, writeConfirmedLine's two throws are covered by the descending-order invariant, the "Ready to write" preview is only shown when nothing is written, the stale block prints the report's coordinate, and hypothesis list/next write nothing; the shared definition — the SARIF emitter takes both names from it and its containers are SARIF's own schema members pinned by test, and the contested/malformed blocks print the paths the stamps actually arrived under, taken from the report rather than from a list.
    • A claim is no longer reported as withheld in the run that wrote it. "One claim, one write" reached writes and not its sibling: withheld was still per-finding and was never reconciled against what the write loop did. Two reachable results. A report where one finding carries the claim key for record R and another is unstamped at R's location — the mixed shape during a scanner stamp rollout — inserted R's @confirmed and then printed ! skipped for that same claim and exited 1, telling bravos the corpus is missing a confirmation that is in it. And two unstamped findings probing one exposure produced two byte-identical skip lines and two byte-identical by-hand commands. Withholding is a fact about a claim: the list is keyed by record.key on the same rule writes uses, then reconciled against the claims that reached source — inserted this run or already present — and both the operator messages and the exit status are computed from the reconciled set. The rule from the round before is untouched and was not widened: a claim whose confirmation is not in the source because we withheld it still exits non-zero, pinned alongside the new cases.
    • A deterministic tiebreak is not agreement, and is no longer reported as though it were. A finding carrying two well-formed claim keys that name different claims is a report contradicting itself about which exposure was tested. The precedence stands — it is what stops a later widening quietly changing what an already-accepted report resolves to — but the losing keys were discarded with no record anywhere, so the output, the ledger and the @confirmed written to source all read exactly like an uncontested stamp. Precedence and silence are separable, and only the precedence was wanted. The rivals are now carried and listed with the field each arrived in, the join is recorded as claim-key-contested rather than claim-key, and --write is withheld: "key-verified" is a claim about what happened, and a label describing a stronger verification than the one performed is the same species as every other false claim this change exists to prevent — in the one place it writes into someone else's repository. The same key arriving in both emitted surfaces is agreement, not conflict, and is unaffected. Refusing to join a self-contradictory finding at all was considered and rejected: it destroys a true result whenever one of the two keys is right, which is the mirror of the defect being fixed.
    • The order is load-bearing, not an optimisation. A coarse join allowed to run first, with the key left only a veto, gets two cases wrong: a claim whose file was edited above it keeps its key but changes line, so a location match would pick whatever now sits on the tested line and declare the live, correctly-stamped finding stale; and a finding carrying nothing but a key — the most precise identifier in the system — would be called unmatched and told to annotate itself first. Both are pinned by test.
    • A key-verified confirmation no longer looks like an unverified one. Each outcome records the identity that joined it (source.joined_by, an additive field existing ledgers validate fine without), joined by claim-key prints as key-verified and every coarse join prints as NOT key-verified, and a report carrying no stamp at all says so once at the top instead of degrading silently.
    • @confirmed results carry no claim key. The verb is part of the digest, so an @exposes and the @confirmed proving it hold different keys, and the ledger keys entries by exposure only — classifyHypotheses resolves a confirmed claim from the annotation and never reads an entry for it. A key stamped from a confirmed result could join to nothing, so none is emitted rather than shipping an identifier that looks usable and is not. Stamping the sibling exposure's key would be worse: it asserts a link the model does not declare. threatId on those results is unchanged.
    • What it separates, measured. On GuardLink's own model at 899b815: 115 exposures across 107 distinct threat ids, so 7 ids are shared by more than one exposure, covering 15 exposures. The claim key separates every member of all 7 — 115 exposures, 115 distinct keys, no collisions.
    • The bound. Two byte-identical claims in one file — same verb, asset, threat, external refs and description — share a digest and are told apart only by an ordinal in document order, <digest>:0 and <digest>:1. Delete the earlier one and the survivor inherits <digest>:0, the deleted claim's exact key, so a finding stamped against the first joins to the second: the shape above surviving, at a strictly narrower population. Of GuardLink's 115 exposures, none relies on an ordinal above zero; across all 669 claims in the model exactly one does, and it is not an exposure. Pinned by test, as is the second limit — rewording a claim's description re-keys it, so a stamp from before the rewording is refused.
    • What the refusal does not cost. The key names the claim, not the code beneath it, so it is unmoved by a line shift and by any edit to the surrounding file — the ordinary drift that makes the ledger expire an outcome loses no confirmation here. That is asserted on the shape it would have cost most: a module-level doc-block, whose anchor hash moves on any edit to its file, still joins after an unrelated function is added.
    • The two sides cannot drift apart again silently. The agreement between what guardlink sarif emits and what --from-scan reads is pinned by tests that forward a real generateSarif result verbatim, naming no field, then run the real importScan — so a rename on either side alone turns them red. They cover both emitted surfaces (partialFingerprints and properties) in both containers (spread and nested): pinning one of two surfaces is not a weaker pin but a false assurance, and that is exactly how the fingerprint gap survived the first version of this test. A further test takes the field name out of the no-stamp banner produced by formatImport and requires the reader to accept that name, so advertising a name the reader refuses fails a test rather than shipping. All of it runs on fixtures where the coarse joins would give the wrong answer (the GAP-58 shape must refuse; the displaced shape must resolve to the moved claim, not to the one now on the tested line) and asserts the provenance is the key rather than a tier. A companion test asserts every result that carries a claim key resolves through importScan, for a model holding both an @exposes and a @confirmed — so nothing stamped is unjoinable.
    • @entitles still has no export semantics: runs[].results and runs[].tool stay byte-identical with and without entitlements, re-asserted against results that carry a claim key (tests/actor-entitlement.test.ts). The regression is pinned by construction rather than by field presence — exposure A stamped at file:line, A deleted, sibling B moved onto that exact line — asserting that the join refuses, that the untouched fixture still confirms, that two @exposes sharing one doc-block are separated, and that the ordinal shape above is not (tests/hypothesis.test.ts, tests/sarif.test.ts).
  • A manual hypothesis confirm/refute no longer records by: "human:human:<name>". Introduced by 497b75e ("feat(hypothesis): a ledger for what happened when an exposure was tested") and present in every manual outcome written since: defaultVerifier already returns human:<git user.name>, and the hypothesis call site prefixed it a second time. The ledger schema, guardlink verify's sibling call site and the command's own --by help all state the contracted form, human:<name> or cxg:<template> — only the code disagreed, so the code moved. An explicit --by was and remains passed through verbatim on this path, which is how --by cxg:login-sqli names its own scheme.

    • Both forms coexist, and nothing needs repairing. by is display and provenance metadata, not a join key: ledger entries are matched on the claim key, by upsert and by every reader. Entries written before this fix keep human:human:<name>, entries written after get human:<name>, and no join, classification or expiry changes because of it. Verified rather than assumed — do not migrate old entries to make them agree.
  • guardlink merge can no longer report a clean estate it never read. Measured on a four-repository fixture, with the glob quoted exactly as link-project's own "Next steps" text tells you to write it: guardlink merge given a per-repo wildcard loaded 0 of 1 repositories, printed 0 unmitigated, wrote workspace-dashboard.html reading clean, and exited 0. Two independent faults produced one answer — nothing expanded the glob that merge --help has always advertised, so the pattern reached readFile as a literal filename; and merge had no verdict at all, only a summary, so the empty model it built left the process as a zero. A user following GuardLink's own printed instructions got a clean bill of health for an estate that was never opened.

    • The glob is expanded by merge, not by luck. resolveReportPaths (src/workspace/merge.ts) expands every dynamic pattern with fast-glob against the working directory, sorts within each pattern so a merged dashboard's repo list cannot reorder between runs on identical input, and de-duplicates a file two patterns both name. A plain path is passed through untouched, so it still reaches loadAllReports and is still reported as a missing repo by name.
    • A pattern that matched nothing fails, and says which pattern. Kept separate from a plain path that does not exist, because they are different statements: a path names one report, a glob names however many repositories there are, and a glob that matched nothing means the caller's description of their estate found no estate.
    • Zero repositories loaded exits 1 regardless of any flag. mergeVerdict is the one place the exit code is decided, and it splits its failures deliberately: an unread estate is not a finding about risk, it is the absence of one, so no opt-in gates it. The counts printed above it are an empty model's and are labelled as such.
    • Nothing is written when nothing was read. No dashboard, no merged JSON. An exit code is consumed once; a file on disk reading "0 unmitigated" is opened next week by somebody who never saw it.
    • A report that could not be opened is named by its repository, not by a 120-character absolute path — orders-api, from the directory, since guardlink-report.json carries no identity of its own.
  • guardlink ci counts annotations, not diagnostics, when it says how much of the model is missing. ci already reported the parse diagnostics; what it could not say is how many annotation lines they stood for. The parser collapses repeats per (file, token) so an unreadable house convention is one warning rather than a flood — on this fleet's flagship repository one convention stood for 1,340 lines, and guardlink status counted 287 malformed annotations there. A reader told "1 warning" concludes one annotation is missing.

    • ParseDiagnostic gains occurrences, set only where the parser collapsed repeats. The number used to be reachable only by reading English out of message, so no consumer of guardlink.ci/v1 could add it up.
    • ci prints Annotations the parser could not read: N whenever it differs from the diagnostic count, with a per-code breakdown that adds up to it; guardlink.ci/v1 gains summary.unparsed_annotations and summary.unparsed_by_code (additive). guardlink status and guardlink validate say the same number in their summary line, so the two surfaces cannot disagree about how many claims are missing.
    • The exit code is unchanged: it still reads diagnostics, and one problem is one problem however many times it was typed. Whether an unreadable annotation should fail a default ci run is a policy question and is not decided here.
  • An estate can no longer be called clean by having been unreadable. guardlink ci reports, per repository, the annotations its parse could not read — and a merged estate could see none of it, because a report JSON said nothing about the parse behind it. So guardlink merge could print a green tick over repositories that had silently dropped their @mitigates lines, which is mark 19's defect one scale up.

    • ReportMetadata gains an optional parse (errors, warnings, unparsed_annotations), written by guardlink report --format json and guardlink parse from the diagnostics that produced the model. MergeTotals gains unparsed_annotations, parse_errors and repos_parse_unknown.
    • Three states, not two. A repo that reported its parse contributes its counts; a repo whose report predates the field is counted in repos_parse_unknown and contributes to neither side. Absence is not zero — reading "this report cannot say" as "it read everything" is the same invented answer as reading "no repositories loaded" as "nothing is wrong". A --strict run that is otherwise clean prints the unknown repos under the tick rather than absorbing them into the word "clean", and does not fail on them: an old report is not a finding.
    • Parse errors gate under --strict; warnings never do — the split ci already makes about the same numbers.
    • "Unsupported language" was never one of these numbers. A file the parser did not scan produced no diagnostic at all — it was never opened to be asked — so the scan-set widening (34 → 73 file types) moves the count's scope without moving its meaning. Measured on a probe carrying a well-formed and a malformed annotation in a newly-read language: before, both were invisible and the model was empty; after, the well-formed one is an exposure and only the malformed one is a diagnostic. A reader chasing unparsed_annotations is chasing annotations that are genuinely unreadable in files that are genuinely read.
  • One team's tag no longer silences another team's finding. Two repositories independently declaring #listing and #bac, with one @mitigates in a third: guardlink merge reported 1 mitigations | 2 exposures | **0 unmitigated** and warned Tag "listing" defined in orders-api (owner) and also in: billing-api. Two teams exposed, one control, nothing open — a real finding disappearing from the estate surface because two teams picked the same word, with nobody told. The warning was right and the count was not: tag_registry already resolved an owner_repo for every tag, and the join that decides coverage never asked. Reproduced as a failing test first (tests/merge-owner-scope.test.ts).

    • The join is scoped by the ownership the registry already computed. A repository that declares the tag itself means its own asset; anything else — the sibling where the control lives — means the owner's. TagOwnership now records also_defined_in (the repos the duplicate warning already named), so the scoping is rebuildable from a merged JSON, where combineModels has deduped the per-repo definition lists away.
    • Assets only, and deliberately not threats. A colliding asset id means two different things. A colliding threat id means the same thing twice: every repository declaring #sqli is a shared vocabulary, not a collision, and scoping that dimension would re-open a cross-repo finding for every repository that declares the standard taxonomy locally. Controls never enter the join.
    • It can only re-open, never hide. The scope starts from the pair match the old key computed and only ever splits a bucket, exactly like the same-file anchor rule beside it. A tag with one definer, or none, scopes to nothing and joins as before — which is every tag in every single-repository model, so ci, validate, sarif, the dashboard and MCP are byte-identical.
    • And it says when it changed the answer. A count that silently grows by one is indistinguishable from someone having written a new @exposes. The new owner_scoped_join warning names the repositories whose findings are open because the only control naming that tag belongs to another repository's asset of the same name.
  • guardlink ci no longer answers an estate question it cannot see. In a multi-repository workspace, guardlink ci <one-repo> is what a developer types and what CI runs, and it cannot see that the @mitigates covering its @exposes lives in a sibling repository. Measured on a four-repo estate: the repository holding the risk exits 1, the repository holding the control exits 0, and guardlink merge over both reports says nothing is unmitigated. Neither per-repo run is wrong about its own tree; both are wrong about the question the reader thinks they answered.

    • It now states its scope. A repository with .guardlink/workspace.yaml gets, on every run: which workspace and which repo this answer is for, every sibling it did NOT read, and the merge → estate route that does answer the estate question. Printed on a clean run as loudly as on a red one — a green tick over one repository of four needs the caveat most.
    • Of the two available repairs this is the honest one, on evidence rather than preference. workspace.yaml carries a workspace name, this_repo, and each sibling's name and remote registry URL. It carries no local path — serializeWorkspaceYaml never writes one and WorkspaceRepo.local_path is documented as setup-only — so ci has no path to a sibling's checkout or report. And the environment ci exists for is a runner with exactly one repository checked out, where a sibling's current model is not on disk under any path. A version that read siblings would work on a laptop with four clones side by side and go quiet in the pipeline, which is the same class of silently-wrong answer the rest of that file exists to remove.
    • It changes what is said and nothing that is found. Same exit code, same counts, same exposures, with and without a workspace; the finding in this tree is real until a merged report says a sibling covers it. A repository with no workspace.yaml prints exactly what it printed before. guardlink.ci/v1 gains summary.workspace, carrying the constant answers: "single-repo" — a consumer left to infer whether a number covers one repository or an estate will infer wrongly, and the two answers differ by exactly this defect.
  • A diagram nothing can draw is no longer written, served, or certified as fine. Measured on a synthetic 257-annotated-file repository: guardlink artifacts wrote a threat graph with 517 edges, guardlink validate . --artifacts printed "✓ Artifacts are current." and exited 0. Past ~500 edges Mermaid's flowchart parser throws and the viewer shows a syntax error; past 50,000 characters it fails silently — mermaid.render resolves normally, the console stays empty, and one pink box reading "Maximum text size in diagram exceeded" is drawn under a toolbar, a Find box and a legend describing a diagram that is not there. Nothing in the product could tell. .guardlink/graph/README.md states the rule this broke: a confidently wrong dataflow diagram is worse than no diagram at all.

    • A render budget, enforced where each diagram is generated (src/dashboard/render-budget.ts). The two limits are Mermaid's own defaults, read out of the shipped bundle at the version the dashboard loads (mermaid@11.17.2) rather than taken from documentation: maxTextSize 50,000 and maxEdges 500, with the enforcing expressions cited in the module. They are measured the way Mermaid measures them — %% comment lines stripped first, so an artifact's provenance header costs nothing, and edges counted per addSingleLink, so A --> B --> C is two and A & B --> C is two.
    • Over budget, the artifact is a stub, not a drawing. .guardlink/graph/*.mmd gets a one-node diagram naming what exceeded and by how much, a %% preamble saying the same in prose, and a pointer to model.json, to the dashboard's Analytics matrix and Threats table, and to by-feature/. MANIFEST.json records renderable and the measurement per .mmd. Verified in a browser under Mermaid's default configuration — the one GitHub, mermaid.live and the VS Code preview use — and the stub draws there. (It opens with graph, not flowchart: under the dashboard's defaultRenderer: 'dagre-d3' a flowchart header fails to parse at all, which the browser check caught.)
    • validate --artifacts answers two questions instead of one. "✓ Artifacts are current." (freshness, unchanged) and "✓ Artifacts are drawable." (new), reported separately because a diagram can be perfectly current and undrawable — which is the case the old single check certified. An undrawable artifact exits 1. The flag is opt-in, its job is to say whether committed artifacts can be trusted, and the gate is self-clearing: one guardlink artifacts . and the same repository is green, because the stub is drawable.
    • The dashboard says so on the page. An over-budget panel gets a plain-HTML notice with the numbers and where to read the same model, the stub in place of the diagram, and no legend keying shapes that are not drawn. mermaid.initialize now passes maxTextSize and maxEdges explicitly — the page loads mermaid@11 from a CDN, so leaving them implicit meant the budget measured against one set of numbers and the renderer enforced another — and mermaid.run is wrapped, so the throwing limit can no longer leave a blank panel and an unhandled rejection.
    • Unchanged, and tested: a repository within budget emits byte-identical .mmd files, and validate --artifacts still exits 0. tests/render-budget.test.ts pins both halves at the measured size, including the CLI exit codes.
  • An acceptance now costs something, and guardlink ci is a gate a team can adopt. Measured on OWASP NodeGoat: one generated file of 67 blanket @accepts lines plus guardlink reanchor --apply took guardlink ci --strict from exit 1 to exit 0 — "✓ No unmitigated exposures, no anchor drift" — while eight @confirmed reproduced exploits sat untouched in the same model, because the gate's predicate had no @confirmed input and no acceptance-quality input. guardlink validate passed at the same moment, merely advising that 111 exposures were accepted without mitigation. Separately, guardlink review accepted a critical plaintext-password exposure on a one-character justification and recorded no author, and a piped invocation exited 0 having written nothing. A gate a team passes by typing is not a gate. Reproduced against a build from source and turned into a regression test (tests/acceptance-cost.test.ts); the same fixture is now red.

    • An acceptance is attributed, justified, scoped and expiring. @accepts <#threat> on <Asset> by "<who>" until <YYYY-MM-DD> -- "why". Both clauses are optional in the grammar so an acceptance written before they existed still parses — refusing to read one would hide it from the check that exists to name it — but an acceptance missing either, or carrying a justification under 24 characters, does not count as an acceptance to guardlink ci --strict: its exposures are reported as unmitigated and the acceptance is listed with what is wrong with it. guardlink validate reports the same as a warning (acceptance-unqualified). Thresholds are per-project in .guardlink/config.json (acceptance.min_justification, require_author, require_expiry, max_horizon_days); a malformed config yields the strict default rather than an off switch.
    • Scope: an acceptance covers the file it is written in, and no other. Measured: one @accepts at app/data/user-dao.js:17 also silenced the identical pair at artifacts/db-reset.js:12 — in the gate and in the SARIF a pentest probes from, so a site nobody had reviewed stopped being something that would ever be tested. @mitigates is untouched and still reaches across files, because a control is code that runs; an acceptance is a person signing for a risk they read, and that does not travel. The narrowing can only ever re-open a finding, never hide one. guardlink review prints the blast radius — how many exposures the line will silence here, how many on the same pair it will not — before asking for the justification.
    • Expiry re-opens. An acceptance past its until date stops covering anything, everywhere, and the gate names it as expired.
    • @confirmed is not silenceable. A reproduced exploit fails --strict regardless of any @accepts or @mitigates on the same pair, and is reported above the theoretical exposures. The way to clear it is to delete the annotation once the exploit no longer reproduces — a visible deletion in a diff.
    • The rule lives in applyReviewAction, not in the CLI prompt. Every writer goes through it: the interactive prompt, the new scripted flags, the --from batch, and the MCP guardlink_review_accept tool, which previously had no check at all. Reviewer-supplied text is collapsed to one line and every built annotation is re-parsed before it is written, so a newline in a justification cannot forge a second annotation.

v2.0.0

2026-08-12

The major version is scoped to two things: the TypeScript type surface and the threat-model JSON schema. No command was removed, no flag was removed, and no output format changed except the threat model's own coverage block. If you use the guardlink CLI or the MCP server, upgrading from 1.4.5 needs no migration — for you this release is additive.

Programmatic consumers are the reason for the major. Eight exported shapes changed, and the breakage reaches code that never imports any of their names — see BREAKING below. Most of it is narrow: four of the eight break only code that constructs our types, such as a test fixture or an adapter. Two reach ordinary reading code — the coverage reshape, and the widened AnnotationVerb union under an exhaustive switch.

Two things a CLI user will nonetheless notice, both described in full further down: merging reports produced by different GuardLink versions now prints a schema-mismatch warning, and externally-anchored projects may see exposures in unmitigated that were previously being hidden by a mitigation on a different symbol.

TypeChanges
BREAKING
  • The coverage block lost two fields and renamed a third.

    // before
    "coverage": { "total_symbols": 0, "annotated_symbols": 105,
                  "coverage_percent": 100, "unannotated_critical": [] }
    // after
    "coverage": { "annotation_count": 105, "coverage_percent": 100 }
    

    What you observe. Reading coverage.total_symbols or coverage.unannotated_critical from guardlink parse, guardlink report --format json, .guardlink/model.json, the guardlink://model resource, or the guardlink_parse / guardlink_status MCP tools now yields undefined. Reading coverage.annotated_symbols yields undefined; the number moved to coverage.annotation_count. In TypeScript you get TS2339: Property 'total_symbols' does not exist on type 'CoverageStats' — and you do not have to import CoverageStats to hit it: reading the field off a parseProject result fails through every one of the seven published subpaths, and hand-constructing a ThreatModel fails with TS2352.

    A consumer that only calls functions — parseProject, generateReport, diffModels, generateSarif — and never touches .coverage compiles and runs unchanged.

    Why they are gone rather than deprecated. total_symbols was always 0 and unannotated_critical was always []. They were constants in a schema presented as public, so nothing downstream could tell not computed from computed, and the answer is zero. Absent says the first; 0 and [] said the second, and three separate consumers believed them — one dashboard rendered "0% coverage" on a fully annotated project, and a merged workspace reported 0% when both its repos reported 89%. GuardLink does no per-symbol parsing, so these fields were never going to be filled in. annotated_symbols is now annotation_count because the old name is what invited the division in the first place.

    Migration. Use annotation_count for the annotation total, and coverage_percent for coverage — noting that it counts files, not symbols and not annotations. The model version moves 1.1.0 → 1.2.0 to mark the change.

  • UnannotatedSymbol is deleted. It existed only to type coverage.unannotated_critical. What you observe: TS2305: Module '"guardlink"' has no exported member 'UnannotatedSymbol'.

  • AnnotationVerb gained 'actor' and 'entitles'. The union behind Annotation['verb'] widened from 20 members to 22, because @actor and @entitles are new verbs — see Added.

    What you observe: nothing, if you read verb or compare it against a literal. If you switch over it exhaustively with an assertNever-style never default, that default no longer compiles:

    error TS2322: Type '"actor" | "entitles"' is not assignable to type 'never'.
    

    Add a default branch, or handle the two new verbs. This is the one breaking change that reaches ordinary reading code rather than only code that constructs our types: Annotation has been exported since 1.x, and an exhaustive switch over a verb union is the natural way to write a renderer or a linter over it.

  • Three returned interfaces gained required fields. InitResult gained preserved: string[]; DiffSummary gained staleEntitlements: number; ThreatModelDiff gained actors: Change<ThreatModelActor>[], entitlements: Change<ThreatModelEntitlement>[] and staleEntitlements: StaleEntitlement[] — note that the last is the list, while DiffSummary.staleEntitlements is its count.

    What you observe: nothing, if you call initProject or diffModels and read the result — that is what these types are for, and reading is unaffected. If you construct one of them — a test fixture, a mock, an adapter that adapts some other tool's output into our shape — the object literal is now incomplete:

    error TS2741: Property 'preserved' is missing in type '{ … }' but required in type 'InitResult'.
    error TS2739: Type '{ … }' is missing the following properties from type 'ThreatModelDiff': actors, entitlements
    

    Add the fields — [] for every list and 0 for the count are the correct empty values. They are required rather than optional because they are always populated on a real result, and an optional field would push a ?? [] into every consumer that reads them.

  • REPORT_SCHEMA_VERSION moved 1.0.0 → 1.1.0, and mixed-version merges now warn.

    What you observe, as a CLI user: merging reports written by different GuardLink versions prints a line that never appeared before.

    ⚠ Reports use different schema versions: 1.0.0, 1.1.0. Results may be inconsistent.
    

    It is advisory. The merge still succeeds and still exits 0. Regenerate the older reports with guardlink report to clear it.

    What you observe, in TypeScript: the constant's type is the string literal, so const v: '1.0.0' = REPORT_SCHEMA_VERSION stops compiling.

    The bump is not cosmetic. The mismatch check compares this value across the reports being merged; leaving it at 1.0.0 would have made the coverage reshape invisible to the one mechanism built to notice exactly that.

  • guardlink_graph replaced traversal.truncated with traversal.completeness.

    What you observe: the boolean traversal.truncated is absent from the MCP response. In its place, traversal.completeness is one of complete (nothing more to find at any depth), depth_limited (correct for the depth you asked for — raise depth for more) or truncated (the depth-10 ceiling cut it short; the result is incomplete and raising depth will not help). When it is not complete, a new frontier_unexplored: { count, nodes } names what lies one hop past the boundary.

    The boolean conflated "I stopped because a limit was hit" with "I stopped because there was nothing left to reach": one asset reported truncated: true at depth 1 and false at depth 2 on an identical 4 nodes and 6 edges. It was removed rather than aliased, because a faithful alias would have to keep reproducing the wrong answer, and a familiar name with changed meaning is worse than a missing one.

Added
  • guardlink ci — advisory CI checks in one command. Runs the two checks GuardLink already performs, against one parse of the model, and reports both: unmitigated exposures, and @source anchors that have drifted off the symbol they name.

    guardlink ci [dir] [-p <project>] [-f text|json] [--strict]
    

    Advisory by default: exit 0 even with findings. A repo that has just adopted GuardLink has unmitigated exposures by construction, and a gate that fails the build the day the annotations land is a gate that gets deleted the week after. --strict is the single opt-in and exits 1 if either check found anything.

    --format json emits a stable document under the schema id guardlink.ci/v1:

    { "schema": "guardlink.ci/v1",
      "exposures": [ /* the parser's own exposure records, unrenamed */ ],
      "drift":     [ /* anchor drift records */ ],
      "summary": { "exposures": 15, "drift": 0, "anchors": 0,
                   "by_severity": { "critical": 0, "high": 3, "medium": 6, "low": 6, "unset": 0 },
                   "by_kind":     { "moved": 0, "symbol_gone": 0, "file_gone": 0, "line_gone": 0 },
                   "strict": false, "exit_code": 0 } }
    

    The exit code is a pure function of (strict, exposures, drift) and is carried in the summary, so a JSON consumer sees the same verdict the shell got. No new detection logic was written: both checks call the same predicates validate and reanchor use, so ci cannot disagree with them about the same model. It reports and never repairs — applying a re-anchor is deliberately not reachable from here.

  • @actor and @entitles — the principal, and the capability held by design. Two verbs answer the question the model had no field for: is the caller already entitled to this effect? @actor Namespace_Admin (#ns-admin) declares a principal in the authorization model — a role, not a person, and distinct from @owns, which names a responsible team. @entitles #ns-admin to configure-archival-destination on #archival-fs -- "… Authz: common/api/metadata.go:189" records that the privilege required to trigger an effect is a privilege that already grants it.

    An entitlement is the only annotation whose failure mode is a silent false negative, so three constraints are enforced rather than documented:

    • It never gates testing, only reporting. Unlike @mitigates and @accepts, @entitles has no export semantics: the exposure stays unmitigated, stays in the SARIF, stays testable. A regression test asserts that SARIF for a model with entitlements is byte-identical to the same model without them.
    • No citation, no effect. An entitlement whose description carries no file:line pointer to the authorization code is inert — parsed and carried so a reviewer can see the claim, flagged by guardlink validate, and ignored by consumers. guardlink diff reports an entitlement whose cited file changed as stale rather than removed.
    • It cannot answer an ownership question. For IDOR and tenant-isolation classes both peers hold the capability, so an entitlement cannot say whose object it was. Ownership stays measured and is deliberately absent from the grammar.

    <capability> must be a single normalised identifier — prose there is a parse error, because it is the join key consumers match on. Surfaced through guardlink status, report, the dashboard, guardlink diff, guardlink sync, and MCP. Purely additive: a model with neither verb parses and exports exactly as before.

  • guardlink entitle and a proposal ledger. An agent proposes; a human accepts. guardlink entitle --propose --actor … --capability … --file … --line … --rationale … writes only to .guardlink/entitlement-proposals.json and never to source. Acceptance is what writes the annotation, under the name of the person who accepted. An @entitles in source with no accepted proposal behind it is a validation error. Also available to agents as guardlink_entitlement_propose / guardlink_entitlement_list; accepting is not.

  • guardlink migrate --to external|inline. Moves a project's annotations between source comments and .guardlink/annotations/*.gal sidecars. Annotation text is moved verbatim rather than re-serialised from parsed objects, and only annotation lines leave the source file, so the round trip reproduces the original file rather than an equivalent one. Every run re-parses and compares the model's content hash before and after and exits non-zero if it moved — the hash excludes exactly the fields a migration may legitimately change, so a moved hash means the threat model itself changed. --dry-run reports without writing. Nothing but this command ever moves an annotation; existing repos are never migrated implicitly.

  • guardlink reanchor, and MCP guardlink_reanchor. Finds @source blocks whose recorded file:line no longer holds the symbol they name — the drift external annotations accumulate after a refactor. Reports four distinct kinds (moved, symbol_gone, file_gone, line_gone) and proposes a corrected line only where the symbol was found elsewhere. --apply moves those; a renamed or deleted symbol is always left to a human, because there is no correct line to move it to.

  • MCP guardlink_annotate_apply. Writes a validated @source block into a file's sidecar — into .guardlink/, never into source — after re-parsing every line with the real parser. Idempotent, returns a diff, invalidates the parse cache, and refuses @accepts and @entitles: both are human governance decisions, and a tool that can write one lets an agent close a finding by declaring it acceptable.

  • A freshness envelope on every MCP tool result. Each response now carries a second content block naming the model it was computed from, so an agent can tell a cached answer from a current one without asking:

    { "guardlink": { "annotation_hash": "sha256-v2:69d3fe…", "git_sha": "95dab747…",
                     "generated_at": "2026-08-12T16:10:33.393Z", "mode": "inline",
                     "root": "/path/to/repo", "guardlink_version": "2.0.0" } }
    

    The same hash appears in the auto-synced block of every agent instruction file, so a block that disagrees with a live tool result is provably out of date. The envelope is applied at tool registration rather than at each return statement, so it covers error branches too.

  • A codified path convention for .gal sidecars. A sidecar belongs at .guardlink/annotations/<source path>.gal — the source path mirrored, with .gal appended. guardlink validate warns about a sidecar that sits somewhere else, and about an on-convention sidecar carrying @source blocks for files other than the one it is named for, which parses fine and is a maintenance trap. Both are warnings, never refusals: an off-convention file still contributes every annotation it carries, because silently dropping a developer's work over a directory choice would be worse than the inconsistency.

  • A warning for an unknown @verb that is close to a real one. @flow and @migitates previously produced neither an annotation nor a diagnostic — the line simply vanished from the model, and the README itself shipped @flow twice. Near misses now warn and name the suggestion:

    ⚠ app/a.ts:2: Unknown annotation verb @flow — did you mean @flows? …
    

    It cannot fail a build. parse, validate, validate --strict, ci and ci --strict all exit 0 on a file whose only problem is one @flow. Only error-level diagnostics reach SARIF, so it cannot surface in GitHub Advanced Security either.

    Three rules keep it quiet in codebases that use @-tags for something else. A token carrying a namespace separator before the verb (@g.comment, @gl:exposes) is treated as a deliberate dialect, not a typo. A 170-entry list of JSDoc, TSDoc, Doxygen, phpDoc and Epydoc tags is excluded by name, so silence on @param is a property of the design rather than an accident of spelling distance. Repeats collapse to one diagnostic per distinct token per file, carrying an occurrence count, so a file with forty of the same typo reports once and still tells you where to start. Measured across four unrelated codebases totalling 13,609 source files: zero false positives.

    Switch it off per project in .guardlink/config.json if it still does not suit you:

    { "diagnostics": { "unknown-verb": false } }
    

    Only warnings can be switched off this way. Listing an error-level code is accepted and ignored — quieting noise is a preference, silencing a broken annotation is not.

  • DiagnosticCode is now an exported type, with twelve members. A code was carried on two diagnostic kinds while roughly seven were emitted without one, and the type itself was never exported — so a consumer wanting to treat a dangling reference differently from risk-acceptance hygiene had nothing to match on but the message text, and no name to match it against. Now every kind carries a code: unknown-verb, duplicate-id, dangling-ref, undeclared-actor, inert-entitlement, imprecise-entitlement, accepted-without-audit, off-convention-gal, stray-gal-source and entitlement-provenance join malformed-annotation and prose-like. ParseDiagnostic gains code?: DiagnosticCode.

    This is additive, not breaking. Neither DiagnosticCode nor ParseDiagnostic.code appears in 1.4.5's published .d.ts, so no 1.4.5 consumer can have been switching over the type or reading the field. If you adopt the type now and switch over it exhaustively, give the switch a default branch — the set will grow again as new diagnostics are added, and a never assertion over it is a compile error waiting for the next release.

  • guardlink parse --no-pretty. --pretty defaulted to true with no counterpart, so the compact branch was unreachable and --no-pretty was rejected as an unknown option. It now emits the model on one line.

  • guardlink-mcp --help and --version. The binary previously ignored every argument and waited for JSON-RPC on stdin, so guardlink-mcp --version hung instead of answering. Both now print and exit without opening a transport; the no-flag invocation still starts the server exactly as before.

  • The published package now contains src/, and its source maps finally resolve. 1.4.5 already shipped 112 source maps that pointed at nothing, because the .ts files they name were not published — every map in the tarball was dead weight. 2.0.0 publishes the 76 source files alongside the 152 maps, so a stack trace from inside guardlink resolves to the original TypeScript and go-to-definition lands on the source rather than the generated .d.ts.

    This is deliberate, and the reason is what the tool is. A security tool asks to be trusted with a threat model; shipping the source it was built from means a consumer can audit what they installed without cloning the repo or trusting a build they did not run. Provenance attestation says the tarball came from this commit — the source in it says what that commit does.

    Cost: 228 → 384 files, 405.8 kB → 962.5 kB packed (1.9 MB → 4.1 MB unpacked). Nothing about the runtime changes: dist/ is what main, exports and both bin entries resolve to, exactly as before.

Changed
  • BEHAVIOUR CHANGE — coverage is decided per site, not per (asset, threat) pair. A @mitigates no longer clears an @exposes when the two are anchored to different symbols in the same file. Everything else is unchanged: a mitigation in a different file still covers the whole asset, an unanchored mitigation still covers the whole asset, and a same-symbol pair still covers.

    Why. A correct mitigation on one function was answering for a live vulnerability on another. Reproduced on a Python service with a %-formatted SELECT in one function and a correctly bound INSERT twelve lines below it: the critical injection was missing from unmitigated, guardlink_context reported no open exposures for the file, and the scanner-triage path answered status: "mitigated" for the vulnerable line. A deterministic scanner asking GuardLink about a true positive was told it was handled.

    What you will see. For inline projects, nothing at all — inline annotations carry no symbol anchor, so the rule cannot engage. Measured: 0 of 74 exposures change state on this repo, 0 of 61 on a second inline repo of 8,142 files, and 2 of 11 on the external repo where the defect was found. If you author externally with symbol anchors, and you have a mitigation and an exposure on the same asset and threat in one file at different symbols, that exposure will now appear in unmitigated. It was always there; it was not being reported.

    How to say "this covers the whole asset". Omit symbol: from the mitigation's @source header. An unanchored statement is an asset-level statement and is never narrowed. No new syntax was added, and none is needed.

    What this does not fix. A cross-file mitigation still blanket-covers its asset and threat. Knowing whether a control actually reaches a site needs a call graph, which GuardLink does not have. The rule closes the class where the model already holds the evidence — the author's own anchors — and no more.

  • guardlink report output is deterministic across processes. The report and Mermaid generators canonicalise the model at the emission boundary. Before: three report --diagram-only runs in three processes produced two distinct hashes, differing by whole node blocks, because the file walk returns completion order under concurrency. After: byte-identical. This changes report --diagram-only output — nodes and edges come out in a deterministic order rather than glob order, so a diff against a previously captured diagram shows reordering once. Nothing is added or removed. The full markdown report is unchanged apart from its two Generated: lines.

  • init --mode and init --no-root-files are separate flags, and the default annotation mode is external. --mode external previously meant two unrelated things at once — annotations live in sidecars, and init writes nothing outside .guardlink/ — so asking for the first silently cost you the root .mcp.json, every agent instruction file, and docs/, which are the things that make an agent aware GuardLink exists. --mode inline|external now decides only where annotations live; --no-root-files decides only the footprint and reproduces the old external behaviour. Existing projects keep their recorded mode — init does not rewrite an existing config.json without --force.

  • .guardlink/model.json and .guardlink/graph/ are tracked in git. A fresh clone has the threat model without running anything, and model changes appear in review. .gitattributes marks them as generated. Exports that are rebuilt on demand — threat-model.json, guardlink.sarif.json, threat-dashboard.html — remain ignored.

  • The dashboard lost two sections. The force-directed Risk Topology graph grew unreadably dense on large codebases; the three Mermaid views (Threat Graph, Data Flow, Attack Surface) remain, and the Threat Graph still auto-filters to high/critical with an All severities toggle. The Pentest Findings page and its detail drawers are also gone, and the dashboard no longer embeds raw scan JSON. Pentest ingestion itself is unchanged — scan results still flow into guardlink threat-report as context, and evidence redaction still applies at load time.

  • The package version is resolved in one place. Four separate implementations of "read package.json at runtime" had accumulated, with two different failure fallbacks. All version-reporting surfaces now agree by construction: guardlink --version, guardlink-mcp --version, the TUI header, the MCP serverInfo, the artifact manifest, SARIF tool.driver.version, and the report's metadata.

Fixed
  • guardlink merge reported "annotation_count": null for any report written by an older GuardLink. Merge reads report JSON from disk, so a 2.x binary meets a 1.4.5 repo's output there; the older field name read as undefined, arithmetic on it produced NaN, and NaN serialises to null. A workspace dashboard reported a null annotation count silently, exit 0, no warning. Reports are now normalised as they are read, and the older field name is carried across rather than replaced with a zero — an old repo keeps its real count.

  • The TUI reported v0.0.0 on any install path containing a space. One of the four version lookups resolved its own location through a URL, which percent-encodes, so the probe missed package.json and the fallback chain bottomed out. guardlink --version beside it reported the truth, which is why it went unnoticed.

  • SARIF reported 1.4.3 as the tool version, regardless of the installed version.

  • guardlink report --format <unrecognised> wrote nothing and exited 0, which is indistinguishable from success. It now names the valid values and exits 1.

  • guardlink tui --model was parsed and never read. It now applies for the session, as --provider and --api-key already did.

  • guardlink status printed unknown as the project name in every repo, including ones whose .guardlink/config.json held the name three lines away. Nothing read it, and it is the first command anyone runs after init. guardlink sync and guardlink entitle were the last two commands still not consulting it and now do.

  • guardlink-mcp did not run. The binary was declared but the module only exported its starter — no shebang, no entry guard — so piping an initialize request at it produced no response at all. The build also now preserves the executable bit on both binaries, without which a fresh install produced a guardlink-mcp that could not be executed.

  • The guardlink entitle --propose example in the generated agent files did not run. CLAUDE.md, AGENTS.md, .gemini/GEMINI.md and .github/copilot-instructions.md are read by coding agents at runtime, and all four carried a worked example missing two required flags, so an agent following it verbatim got an error instead of a proposal.

  • init wrote a .guardlink/README.md whose reference pointer did not exist. The README chose the path by annotation mode while init wrote it by footprint, and under the default those two disagree — which is every fresh repo. The sentence after the broken link is "Read it before inventing syntax", so the one dead pointer in the document was the one aimed at a reader about to guess.

  • init did not create .gitignore when a project had none, only appended to an existing one — so a fresh repo got no entry at all, and guardlink dashboard left threat-dashboard.html both untracked and unignored in exactly the repos least likely to notice.

  • init's "Next steps" contradicted the README it wrote in the same run. Step 2 said "add annotations to your source files" regardless of mode, while the README written beside it says, under the default, that annotations do not go in source files. Step 2 now follows the resolved mode.

  • The MCP write path accepted what it promised to reject. guardlink_annotate_apply documented that malformed input is rejected with a reason, then accepted an undeclared #reference and a source file that does not exist, each with ok: true and no errors — so an invented reference survived the write, the validation and CI. References are now checked against the model's declared ids, using the same rule the dangling-reference check uses.

  • The MCP envelope reported every external project as mixed. Asset, threat and control declarations are structurally inline-only, and counting them as inline evidence meant a correctly configured pure-external repo reported the alarm state. Detection now asks only the relationship verbs, which are the only ones with a genuine choice of home. mixed still fires on a genuinely mixed repo.

  • @shield regions survive migration. The markers delimit a region of source text and mean nothing outside the file whose lines they bracket, so they no longer migrate, and annotations inside a shielded region are no longer extracted. Both were caught by the hash gate: externalising the markers unshielded this repo's own documentation examples, and extracting from inside the region turned 38 examples into real records.

  • guardlink_context told agents the .gal path convention was not codified. It is. The tool now states the convention an agent can act on.

v1.4.5

2026-07-21
TypeChanges
Fixed
  • guardlink --version now reports the correct version. The CLI hardcoded its version string (.version('1.4.3')) independently of package.json, so bumping and publishing did not update what --version printed — the published 1.4.4 still reported 1.4.3. The version is now read from package.json at runtime, so it can never drift again. Added a regression test that fails if a hardcoded version literal is reintroduced. (The 1.4.4 crash fix itself was unaffected — only the reported version string was wrong.)

v1.4.4

2026-07-21
TypeChanges
Fixed
  • init / sync no longer crash on agent-file path-type conflicts. When a project already contained an agent-tool config whose type differed from what GuardLink expected — most commonly an older single-file .cursor/rules (a file) where GuardLink writes the newer .cursor/rules/ directory layout — guardlink init threw a raw ENOTDIR and aborted before creating anything. The mirror case (an agent path such as CLAUDE.md existing as a directory) threw EISDIR. Both are now detected: initialization completes normally — .guardlink/ and all non-conflicting agent files are created, and the conflicting path is left untouched rather than clobbered. Applies to both init and sync. Added regression tests covering both conflict directions, idempotency, and the clean-repo path.
Internal
  • Groundwork for merging GuardLink into a legacy single-file .cursor/rules (rather than skipping it) is present but not yet wired into agent detection/selection; it will be enabled in a follow-up once the picker recognizes the legacy layout.

v1.4.3

2026-05-13
TypeChanges
Added
  • Multi-hop @flows chains — @flows A -> B -> C -> D is now valid syntax for chains of any length, expanding into N-1 pairwise flows that share the same mechanism, description, and source location. Single-hop syntax (A -> B) unchanged. Downstream consumers (DFD, sequence diagram, MCP queries, SARIF) still see the pairwise shape — multi-hop is purely a parser-side expansion.

  • Quoted asset and threat refs in relationships — ASSET_REF and THREAT_REF now accept double-quoted strings as a third alternative alongside #id and Dotted.Path. Example: @flows User -> "/rest/user/login" -> "SQLite db" parses cleanly. Same syntax works in @exposes, @confirmed, @boundary, @audit, and other relationship verbs. Definition annotations (@asset, @threat, @control) remain strict — declarations stay on #id and dotted paths.

  • Opt-in pentest evidence redaction (guardlink config set redact-evidence true) — surgical redaction for teams whose compliance posture requires no cleartext credentials at rest. When enabled, JWT signatures are stripped (header + payload preserved as proof of exploit), Authorization: Basic/Digest/NTLM values are fully redacted, credential field values in JSON / query-strings / cookies are masked (field names preserved). Default OFF; OSS users running against test targets see full evidence. Dashboard shows a banner when redaction is active. Full operational guide: docs/handling-evidence.md.

  • @confirmed annotation — New verb for verified exploitable findings. Distinct from @exposes (theoretical) and @accepts (governance). Syntax: @confirmed #threat on Asset [severity] cwe:CWE-NNN -- "evidence". A @confirmed annotation means the threat has been proven exploitable through pentest, automated CXG scan with reproducible evidence, or manual reproduction — not a false positive. Full pipeline: parser, model assembly, dangling-ref validation, SARIF error-level export, CLI status output, dashboard emphasis, LLM report inclusion, MCP guardlink_lookup "confirmed".

  • @feature annotation — New metadata verb to tag files/code with a named product feature. Syntax: @feature "Feature Name" -- "description". Association is file-level: all annotations in a file with @feature "X" are considered part of that feature. Enables feature-scoped filtering across all output modes.

  • Feature filtering (--feature flag) — guardlink status, guardlink report, and guardlink dashboard all gain --feature <names> (comma-separated). Filters all output — assets, threats, exposures, flows — to files tagged with the named feature(s). Dashboard gets a live feature filter dropdown in the header with a dismissible banner. TUI gains /feature [name] command to list features or drill into one.

  • guardlink translate [prompt] — New command that translates GuardLink threat model findings into CERT-X-GEN (CXG) pentest templates (generation only, no execution). Supports all agent backends: --claude-code, --codex, --gemini, --cursor, --windsurf, --clipboard. Reads CXG reference docs and skeleton templates from GUARDLINK_CXG_ROOT env or configured default path.

  • guardlink ask <query> — New command that answers natural-language questions about the threat model and codebase context, launching an AI agent with full model serialization as context.

  • Pentest integration — GuardLink now loads CXG scan results from .guardlink/pentest-findings/ (JSON) and template metadata from .guardlink/cxg-templates/. New interfaces: PentestFinding, PentestScanResult, PentestTemplate, PentestData. Findings are injected as a <pentest_findings> block into AI threat reports, guardlink threat-report, and the dashboard. Dashboard gains a dedicated Pentest Findings sidebar section with scan summary tables and per-finding detail drawers.

  • Expanded threat model report (guardlink report) — generateReport() now produces 10 structured sections (was: Executive Summary + tables):

    1. Application Overview (auto-populated from .guardlink/prompt.md if present)
    2. Scope of This Threat Model
    3. Architecture (Mermaid DFD)
    4. Key Flows & Sequence (new Mermaid sequence diagram from @flows)
    5. Data Inventory
    6. Roles & Access
    7. Dependencies
    8. Secrets, Keys & Credential Management
    9. Logging, Monitoring & Audit
    10. AI/ML System Details (conditional — emitted only when AI-related threats are detected)

    Report header now includes GuardLink version and git commit/branch from metadata. Confirmed exploitable findings appear as a row in the Executive Summary table.

  • Sequence diagram (src/report/sequence.ts) — New Mermaid sequenceDiagram generator built from @flows annotations, showing step-by-step participant interactions. Used in the Key Flows & Sequence report section.

  • .guardlink/prompt.md — guardlink init and guardlink sync now create this skeleton file. AI annotation agents fill it in with a security-focused project overview (what the app does, components, trust boundaries, data sensitivity, deployment). guardlink report reads it and injects the content as the Application Overview section.

  • SARIF: confirmed exploitable rule — New guardlink/confirmed-exploitable SARIF rule emitting error-level results for @confirmed annotations. These appear alongside unmitigated exposures in GitHub Advanced Security.

  • MCP guardlink_lookup queries — Two new query types: "confirmed" returns all @confirmed verified findings; "features" returns all @feature-tagged feature names with their associated files.

  • LLM prompt improvements — buildUserMessage() accepts pentest findings context. AI prompts now distinguish pentest-confirmable threats from governance/design gaps, and teach agents when to use @confirmed vs @exposes vs @audit.

Changed
  • guardlink status — Now prints @confirmed findings with a red badge below the exposure list. Accepts --feature for filtered output.
  • guardlink report — Accepts --feature for scoped reports. Reads .guardlink/prompt.md for Application Overview.
  • guardlink dashboard — Accepts --feature. Risk score formula now accounts for confirmed finding count. Feature filter dropdown in header.
  • guardlink threat-report — Pentest findings from .guardlink/pentest-findings/ are automatically included in AI analysis context. AI prompted to emit a dedicated "Pentest Results" section when findings are present.
  • /gal TUI command — Documents @feature tagging with examples.
  • SARIF export — @confirmed findings now appear as error-level entries under the new rule; @exposes severity mapping unchanged.
  • MCP server — Status tool description updated to reflect confirmed count. guardlink_lookup extended with confirmed and features queries.
Fixed
  • guardlink report no longer prints "Fix errors above before generating report" when diagnostics contain errors — the message was misleading because the report generated anyway. Per-annotation parse errors don't block report generation; affected annotations are skipped while the rest of the model still renders. Behavior now matches dashboard, sarif, and threat-report.
  • MCP guardlink_lookup resolver agrees with itself across query types — asset #login previously returned count: 0 when an identifier was referenced (e.g. via @confirmed) but never declared in definitions.ts, even though threats for #login, unmitigated, and confirmed all returned the joined record. Bare #id queries had the same problem — they returned no_match for identifiers other queries happily resolved. Both lookupAsset() and lookupFuzzy() now fall back to the annotation graph (exposures, confirmed, mitigations, acceptances, audits, flows, boundaries) and synthesize stub records marked declared: false with a referenced_in: [...] audit trail. Consumers can distinguish synthesized stubs from real declarations.
  • MCP guardlink_lookup no_match hint no longer mangles its quotes — the hint contained literal double-quote characters that got escaped twice through the MCP transport (content wrap + JSON-RPC envelope), rendering as \\\"asset <n>\\\" in clients that print the raw response. Hint now uses backticks around examples so it survives both JSON.stringify passes intact.
  • Pentest template card titles in the dashboard now show the actual template id (e.g. login-sqli-network) instead of fragments like ge or e. The previous loader regex /id[:\s]*["']?([a-z0-9_-]+)["']?/i matched the substring "id" inside words like bridge and guide.
  • Pentest template card severity is no longer hardcoded to medium — the loader's severity regex required a colon between the field name and the value, missing Python templates that use severity = "critical" (equals separator). Both regexes now anchor on a complete field name with optional surrounding quotes (for JSON "id": "x" form) and accept : or = as the separator before a quoted value.
  • guardlink status row labels — renamed the file-counting rows from Annotated/Not annotated to Files annotated/Files unannotated, removing the visual collision with the Annotations row directly below. The count of files-with-annotations is no longer easily misread as the total annotation count.
  • Pentest finding confidence renders defensively across CXG output shapes — the dashboard previously hardcoded ${f.confidence}%, assuming integer percentage. CXG has emitted confidence as integers, severity-style strings ("high"), and missing values across versions; the inline rendering produced high%, undefined%, and even [object Object]%. New formatConfidence() helper handles every case, clamps integers to [0, 100], and never throws. The dashboard still shows 50% for every finding today because CXG itself hardcodes that — a CXG-side fix lands separately; GuardLink will display the correct value when it does.
  • Topology dedupes undeclared refs across kinds — an undeclared identifier like #login-sqli referenced as both an asset (by @exposes) and a threat (by @confirmed) previously synthesized two separate nodes in different clusters of the force-directed dashboard graph. The alias resolver now does cross-kind dedup before synthesizing; declared assets/threats/controls always take priority. New declared: boolean field on topology nodes lets downstream consumers distinguish synthesized stubs from real declarations.
  • Multi-hop @flows annotations are no longer rejected — @flows User -> /api -> DB previously failed with Malformed @flows annotation: could not parse arguments because the regex required exactly two ASSET_REF captures separated by a single arrow. See Added section for the new multi-hop syntax.
  • URL-style and whitespace-containing refs work in @flows and other relationships — /rest/user/login, "SQLite db", "Auth Service" now parse where they didn't before. The ASSET_REF regex previously accepted only #id and Dotted.Path forms. See Added section for quoted-ref syntax.
  • .guardlink/prompt.md auto-migrates for v1.4.x projects on first guardlink report — projects upgraded from earlier versions didn't have the new file (since guardlink init short-circuits when .guardlink/ exists), causing reports to silently fall back to a boilerplate Application Overview. Now created automatically on first report with a one-line stderr nudge so the user discovers the feature. Existing user content is never overwritten; the operation is idempotent. New ensurePromptMd() helper in src/init/migrate.ts.
Internal
  • Generated samples moved to docs/examples/ — threat-dashboard.html, threat-model.md, and guardlink-pentest.{html,json,sarif} were previously committed at the repo root, where every guardlink dashboard . run from the project root rewrote them and produced churn in unrelated PRs. They now live under docs/examples/ (with a README.md documenting how to regenerate them deliberately) and the root paths are git-ignored.
  • fatal diagnostic tier reserved — ParseDiagnostic.level extended from 'error' | 'warning' to 'error' | 'warning' | 'fatal' with detailed JSDoc explaining tier semantics. No code path currently emits a fatal; this is a non-breaking type widening so v1.6 can introduce the first emission site (for unrecoverable conditions like schema version mismatch or unparseable definitions) without a coordinated cross-file change. New diagnosticIcon() helper in src/parser/format.ts centralizes the level → icon mapping (✗✗ / ✗ / ⚠); CLI and TUI printers use it consistently. A TODO(fatal-tier) note in src/types/index.ts enumerates the 11 audit sites that need updating before the first emission lands.
  • Test coverage — new test files: tests/lookup.test.ts (14 tests across the MCP query DSL with regression guards for the resolver bugs), tests/pentest-loader.test.ts (10 tests covering JSON/Python/YAML conventions for template metadata extraction), tests/format.test.ts (9 tests for confidence rendering across number/string/missing inputs), tests/migrate.test.ts (5 tests for prompt.md migration outcomes including idempotence), tests/diagnostics.test.ts (7 tests covering the fatal-tier vocabulary and icon mapping), tests/redact.test.ts (27 tests for surgical evidence redaction including JWT split-redact, Authorization header variants, JSON / query-string / cookie credential patterns, object-key inspection, and safety properties), plus extensions to tests/parser.test.ts (+19 tests for multi-hop chains and quoted refs) and tests/dashboard.test.ts (+4 tests for cross-kind topology dedup). Suite total: 72 → 167.

v1.4.2

2026-04-24
TypeChanges
Added
  • CLI: guardlink annotate --mode external — generate annotations as standalone .gal files under .guardlink/annotations/ that mirror the source tree, instead of as inline comments in source files. Source files remain unchanged. Useful for vendored code, audit-controlled repositories, and projects where modifying source files is politically expensive. Contributed by @jordi-murgo in #6.
  • CLI: guardlink annotate --stdout — print the annotation prompt to stdout instead of launching an agent or copying to the clipboard. Useful for piping into custom harnesses and CI pipelines. Contributed by @jordi-murgo in #6.
  • Parser: @source file:<path> line:<n> [symbol:<name>] directive — anchors annotations in a .gal file to a logical source-code location. The directive produces no annotation itself; it sets the location for subsequent annotations until the next @source or end of file.
  • Types: SourceLocation.origin_file and SourceLocation.origin_line — physical location of an annotation (the .gal file path), preserved alongside the logical location (file / line) for dashboards, reports, and SARIF to surface provenance where useful while defaulting to the logical source location for developer-facing output.
Changed
  • guardlink init --mode external: contains GuardLink's entire footprint inside .guardlink/ — no CLAUDE.md / AGENTS.md / .cursor/rules/ files at the project root, no .mcp.json at the root, no docs/GUARDLINK_REFERENCE.md. The reference doc and MCP config template are placed inside .guardlink/ instead.
  • Review writeback: @accepts and @audit annotations generated via guardlink review are written to the annotation's physical location (the .gal file in external mode) rather than the logical source location, preserving external mode's "source files untouched" property through governance workflows.
  • Review writeback: comment-style detection now correctly handles HTML (<!-- ... -->) and CSS (/* ... */) files. Previously these fell back to JavaScript-style // comments, producing invalid markup. Contributed by @jordi-murgo in #6.
  • Review exposure IDs: composite writeFile:writeLine:logicalFile:logicalLine:asset:threat scheme replaces the previous file:line scheme. Prevents two @exposes annotations at the same source location from colliding on the MCP review identifier. Contributed by @jordi-murgo in #6.
  • Review insertion: TypeScript and Python decorators starting with @ are no longer mistaken for GuardLink annotations when walking the "coupled block" during writeback. Contributed by @jordi-murgo in #6.
  • Parser **/*.gal discovery is now case-insensitive. Contributed by @jordi-murgo in #6.
Fixed
  • Agent prompts: wrap the external-mode example annotation block in @shield:begin / @shield:end to prevent guardlink validate from parsing the JavaScript string literals inside src/agents/prompts.ts as real annotations (resolved four parse errors in the CI dogfood step after #6 merged).
  • Documentation: correct --mode inline|gal references to --mode inline|external in README.md (two occurrences), docs/GUARDLINK_REFERENCE.md (three occurrences including the TUI /annotate slash-command help text). The flag value shipped as external; the docs referenced the prototype name gal.
  • Documentation: document --stdout flag on the AI-agent flags cheat-sheet in docs/GUARDLINK_REFERENCE.md.
  • Documentation: add @source convention note to the standalone .gal files section in docs/GUARDLINK_REFERENCE.md — annotations placed before the first @source directive fall back to the .gal file's own physical location, which is rarely what users want.
Chore
  • Version: bump from 1.4.1-gal development tag (landed via #6) to 1.4.2 across package.json, package-lock.json, src/cli/index.ts, and src/mcp/server.ts.
  • Lockfiles: remove committed bun.lock (landed via #6). This project standardizes on npm; package-lock.json is canonical. Added bun.lock, yarn.lock, and pnpm-lock.yaml to .gitignore so contributors using alternate package managers locally do not accidentally commit a second lockfile.

v1.4.1

2026-03-12
TypeChanges
Fixed
  • GAL reference (/gal, guardlink gal): Fixed all syntax examples to match the actual parser — descriptions now correctly show -- "quoted text" format instead of the non-functional : text format; severity now shows bracket notation [high] / [P0] instead of severity:high; @flows now shows -> arrow syntax instead of to; @validates now shows for preposition instead of on; @owns now includes the required for preposition; @mitigates now documents using as the primary keyword (with with as v1 compat)
  • GAL reference: Added missing documentation for external references (cwe:CWE-89, owasp:A03:2021, capec:CAPEC-66, attack:T1190) on @threat and @exposes annotations
  • GAL reference: Added missing @boundary alternate syntaxes (@boundary between A and B, @boundary A | B) and (#id) support
  • GAL reference: Added missing standalone @shield single-line marker (was only documenting @shield:begin/end blocks)
  • TUI /help: Added missing /unannotated command to the help output (was registered and functional but not listed)
  • CLI version: Fixed guardlink --version reporting 1.1.0 instead of the actual package version
Changed
  • GAL reference: Added new "External References" section explaining cwe:, owasp:, capec:, attack: ref syntax
  • GAL reference: Updated Tips section with description format, severity format, and @flows -> syntax reminders
  • Annotations: Changed @comment to @audit on agent-launcher timeout note for better governance visibility
  • Annotations: Added @audit to MCP suggest module, added workspace-related controls to definitions

v1.4.0

2026-02-27
TypeChanges
Added
  • Workspace: Multi-repo workspace support — link N service repos into a unified threat model with cross-repo tag resolution, weekly diff tracking, and merged dashboards
  • Workspace: guardlink link-project <repos...> --workspace <name> --registry <url> — scaffold workspace.yaml in each repo, auto-detect repo names from git/package.json/Cargo.toml, inject cross-repo context into agent instruction files
  • Workspace: guardlink link-project --add <repo> --from <existing> — add a repo to an existing workspace with sibling auto-discovery
  • Workspace: guardlink link-project --remove <name> --from <existing> — remove a repo from workspace, update all siblings found on disk
  • Workspace: guardlink merge <files...> — merge N per-repo report JSONs into a unified MergedReport with tag registry, cross-repo reference resolution, stale/schema warnings, and aggregated stats
  • Workspace: --diff-against <prev.json> flag on merge for week-over-week risk tracking (assets/threats/mitigations/exposures added/removed, risk trend, unresolved ref changes)
  • Workspace: -o <file> dashboard HTML output + --json <file> merged JSON output + --summary-only text mode
  • CLI: guardlink report --format json — JSON report output with metadata (repo, workspace, commit SHA, schema version)
  • TUI: /workspace — show workspace config, sibling repos, registries
  • TUI: /link — link repos with --add/--remove support
  • TUI: /merge — merge reports with --json, --diff-against, -o flags
  • MCP: guardlink_workspace_info tool — returns workspace name, this_repo identity, sibling tag prefixes, and cross-repo annotation rules for agents
  • Parser: External reference detection — scans relationship annotations for tags with dot-prefix matching sibling repo names from workspace.yaml, populates ThreatModel.external_refs
  • Types: ExternalRef interface, ThreatModel.external_refs field, ReportMetadata with repo/workspace/commit_sha/schema_version
  • CI: examples/ci/per-repo-report.yml — per-repo workflow: validate on PRs (diff + SARIF + PR comment), generate + upload report JSON on push to main
  • CI: examples/ci/workspace-merge.yml — weekly workspace merge workflow: download all repo artifacts, merge, dashboard, weekly diff, optional GitHub Pages + Slack
  • Docs: docs/WORKSPACE.md — multi-repo setup guide, workspace.yaml spec, cross-repo annotation rules, merge behavior, CI integration, weekly workflow
Changed
  • MCP: Server version bumped to 1.4.0

v1.3.0

2026-02-27
TypeChanges
Added
  • Review: guardlink review — interactive governance workflow for unmitigated exposures across CLI, TUI (/review), and MCP (guardlink_review_list + guardlink_review_accept). Users walk through exposures sorted by severity and choose: accept (writes @accepts + @audit), remediate (writes @audit with planned-fix note), or skip. Mandatory justification prevents rubber-stamping; timestamped audit trail for compliance.
  • CLI: guardlink clear — remove all annotations from source files to start fresh, with --dry-run preview and --include-definitions option
  • CLI: guardlink unannotated — list source files with no annotations, showing coverage ratio
  • CLI: guardlink sync — standalone command to sync agent instruction files with current threat model (previously only available via MCP/TUI)
  • TUI: /review, /clear, /sync, /unannotated commands
  • MCP: guardlink_review_list, guardlink_review_accept, guardlink_unannotated, guardlink_clear, guardlink_sync tools
  • Dashboard: File Coverage section on Code & Annotations page with progress bar and collapsible unannotated file list
  • Parser: annotated_files and unannotated_files fields added to ThreatModel
  • Templates: Sync guidance in workflow section for all 7 agent instruction formats
  • Templates: Tightened negative guardrail — agents prohibited from writing @accepts (human-only via guardlink review)
  • Auto-sync: status and validate commands now auto-sync agent instruction files after parsing
Fixed
  • Parser: @shield:begin/@shield:end blocks now properly exclude content from the threat model. Previously, example annotations inside shielded blocks were parsed as real annotations, causing duplicate ID errors and dangling reference warnings.
  • Init: Picker "All of the above" now uses a numbered option instead of a shortcut for consistency
Changed
  • MCP: Server version bumped to 1.3.0

v1.2.0

2026-02-22
TypeChanges
Added
  • LLM: Multi-provider support — Anthropic, OpenAI (Responses API), Google Gemini, DeepSeek (reasoning), Ollama, and OpenRouter
  • LLM: Tool-call system with CVE lookup (NVD), finding validation, and codebase search for grounded threat analysis
  • LLM: Extended thinking / reasoning token support for DeepSeek and Anthropic models
  • Analyze: Project context builder — automatically assembles architecture summary, data flows, and unmitigated exposures for LLM context
  • Analyze: Code snippet extractor — injects relevant source around annotations into threat reports
  • CLI: threat-report now accepts custom freeform prompts in addition to framework names
  • CLI: --provider, --model, --api-key, --web-search flags for threat report generation
  • CLI: Inline agent execution mode in launcher
  • TUI: Model catalog with provider selection (Anthropic, OpenAI, Google, DeepSeek, Ollama, OpenRouter)
  • TUI: Custom prompt input for threat reports alongside framework presets
  • TUI: Inline agent execution from TUI sessions
  • TUI: Restored /exposures, /show, /scan commands for exposure browsing and coverage scanning
  • Dashboard: Collapsible sidebar with SVG navigation icons and localStorage state persistence
  • Dashboard: Exposure computation helpers (computeExposures)
  • Docs: Updated GUARDLINK_REFERENCE.md and SPEC.md with new capabilities
  • Validation: Additional parser diagnostics
Fixed
  • LLM: Anthropic model IDs now use aliases (claude-sonnet-4-6, claude-opus-4-6) instead of invalid snapshot dates
  • Dashboard: Mermaid diagram render trigger restored on first Diagrams tab visit
  • TUI: CLI artifact cleaning (cleanCliArtifacts) for stripping agent-specific output formatting
  • CI: OIDC trusted publishing preserved across merges (npm ≥11.5.1, no registry-url override)
Changed
  • CLI: threat-report signature changed from [framework] [dir] to [prompt...] -d <dir> — directory is now a flag, prompt accepts freeform text
  • Prompts: Reframed annotations as developer hypotheses to validate rather than mandates, improving LLM annotation quality
Removed
  • Util: Removed empty src/util/ansi.ts placeholder (functionality already in src/tui/format.ts)

v1.1.0

2026-02-21
TypeChanges
Added
  • Validation: Shared findDanglingRefs and findUnmitigatedExposures with consistent #id/bare-name normalization across CLI, TUI, and MCP
  • Validation: Expanded dangling ref checks to cover @flows, @boundary, @audit, @owns, @handles, @assumes annotations
  • Diagrams: Threat graph now renders @transfers, @validates, trust boundaries, data classifications, ownership, and CWE references
  • Diagrams: Heuristic icons for assets (👤 user, 🖥️ service, 🗄️ database) and flow mechanisms (🔐 TLS, 🌐 HTTP, 📨 queue)
  • Prompts: Flow-first threat modeling methodology with architecture mapping, trust boundary identification, and coupled annotation style guide
  • Prompts: Agent context now includes existing data flows and unmitigated exposures for smarter annotation
  • Model: Two-step /model configuration — CLI Agents (Claude Code, Codex, Gemini) or API providers
  • Tests: Dashboard diagram generation tests (label sanitization, severity resolution, transfers, validations)
  • Tests: Parser regression tests (@flows via + description, @shield vs @shield:begin disambiguation)
  • Tests: Validation unit tests (dangling refs, unmitigated exposure matching with ref normalization)
  • README: Manual installation instructions (build from source + npm link)
Fixed
  • Parser: @flows regex no longer swallows description when via mechanism is present
  • Parser: @shield no longer incorrectly matches @shield:begin and @shield:end
  • Validation: #id and bare-name refs now compare correctly (e.g., #sqli matches sqli in mitigations)
Removed
  • TUI: /scan command — redundant with /status coverage display; AI-driven annotation replaces manual symbol discovery
  • TUI: /exposures and /show commands — exposure data remains accessible via /validate, MCP guardlink_status, and guardlink://unmitigated resource
  • Dependencies: Removed accidental build package (unused)

v1.0.0

2026-02-21

Initial public release of GuardLink.

TypeChanges
Added
  • Parser: 16 annotation types, 25+ comment styles, v1 backward compatibility
  • Parser: External reference support (cwe, capec, owasp), severity levels
  • Analyzer: Coverage statistics, dangling ref detection, duplicate ID detection
  • Analyzer: SARIF 2.1.0 export for GitHub/GitLab Security tab
  • Analyzer: Suggestion engine with 14 patterns for common security scenarios
  • Diff: Threat model comparison between git refs, change classification
  • Report: Markdown report with executive summary and Mermaid DFD diagram
  • Report: Compact diagram mode for high-exposure codebases
  • Init: Project initialization with multi-agent support (Claude Code, Cursor, Windsurf, Cline, Codex, GitHub Copilot)
  • Init: Behavioral directive injection for automatic annotation by AI agents
  • MCP: 12 tools (parse, validate, status, suggest, lookup, threat_report, threat_reports, annotate, report, dashboard, sarif, diff) and 3 resources
  • CLI: 12 commands (init, parse, status, validate, report, diff, sarif, mcp, threat-report, annotate, dashboard, scan)
  • TUI: Interactive terminal interface with command palette, autocomplete, and inline help
  • Dashboard: HTML threat model dashboard with exposure explorer, file tree, and threat report viewer
  • Agents: Unified agent launcher (Claude Code, Cursor, Windsurf, Cline, Codex, Gemini CLI) with config resolution chain
  • Threat Reports: AI-powered threat analysis using STRIDE, DREAD, PASTA, and other frameworks
  • CI: --strict flag on validate, --fail-on-new on diff for CI gates