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