Design: Suspect Links + Content Hashing¶
Status: Implemented (v1) — decisions ratified in ADR-SYS-SUSLINK-001 Date: 2026-07-12
Implementation note. v1 landed as
crates/syscribe-model/src/suspect.rs(projection + BLAKE3 hash +scan), thetraceBaselinesfield onRawFrontmatter, warningW090in the validator, and thesuspect list/acceptCLI (crates/syscribe/src/suspect.rs). RequirementsREQ-TRS-SUS-LINKS-000..007, testsTC-TRS-SUS-LINKS-001..007/100/101(crates/syscribe/tests/suspect_links.rs). The four open decisions in §8 are resolved below.
A mechanism to detect when a trace link's target has changed since the link was last reviewed, and to surface those links as suspect so they can be re-reviewed. The change signal is a content hash of the target captured at review time, stored next to the link.
This is the classic DOORS/RM "suspect link" concept, adapted to Syscribe: the hash makes it version-control-agnostic and portable across repos, which matters for multi-repo composition (§14).
1. Motivation¶
Trace links (verifies, derivedFrom, satisfies, …) assert a relationship that was valid at the moment a human reviewed it. When the target later changes, the assertion may no longer hold — a test may no longer cover the requirement it verifies; a child requirement's rationale may be invalidated by an edit to its parent. Today nothing flags this.
git diff can say "the target file changed," but that is a poor signal: it fires on typos, field reordering, and doc-only edits, producing suspect storms that train reviewers to ignore the flag. We want a low-false-positive, semantically meaningful signal.
Relationship to multi-repo ref-drift (E510–E515)¶
RefState (crates/syscribe-model/src/config.rs:118) already detects when a peer repo's HEAD drifts from its pinned ref:. That is whole-repo, commit-granularity. It cannot see:
- an individual referenced element's content changing while the repo stays on the same commit, or
- intra-repo suspect links (same repo, no ref involved).
A per-element content hash operates at element granularity within a single repo, so it is complementary, not redundant — it fills the gap below RefState's resolution.
2. Core mechanism¶
A suspect link is a trace link whose target changed after the link was last reviewed.
- When a reviewer accepts a link A→B, capture a baseline hash of B's normative projection (see §3) and store it on A, next to the link.
- At validation, recompute B's projection hash and compare to the stored baseline.
- Mismatch ⇒ the link is suspect ⇒ emit a warning.
- A reviewer clears it by re-baselining (capture the current hash).
Three decisions define the feature: (1) what we hash, (2) where the baseline lives, (3) how suspicion propagates.
3. What we hash — normative projection (Axis 1)¶
Decision: hash a per-type "trace-significant" projection, not the whole file.
| Option | Behavior | Verdict |
|---|---|---|
| A. Whole file (frontmatter + body) | Any edit flips every incoming link | ✗ suspect storms; reproduces git diff with no added signal |
| B. Normative projection | Hash only fields downstream consumers depend on, per type | ✓ chosen — meaningful, low false-positive |
C. Explicit @normative region | Author marks the span | ✗ manual, easy to forget |
Why B is also the easiest to implement here¶
Elements retain only the parsed RawFrontmatter struct + markdown body — the raw YAML text is not stored (crates/syscribe-model/src/frontmatter.rs:6). But there is already a precedent for canonical serialization:
- frontmatter → canonical JSON with null-stripping (
crates/syscribe/src/export.rs:44) custom_fieldsis aBTreeMapspecifically for deterministic sorted output (element.rs:759)
So the projection is simply: select a subset of struct fields → serialize to canonical JSON (sorted keys) → hash the bytes. This solves canonicalization for free — serde produces deterministic bytes when we control field selection and use sorted maps. Whole-file hashing would be more work (re-read the file, normalize line endings/whitespace) and worse signal.
Projection contents (starting point — refine per type)¶
The projection should contain the fields a downstream consumer actually depends on. Default surface, then per-type overrides:
- Requirement: normative body text +
reqDomain+ safety fields (SIL/ASIL).statusand the claim/assignment fields are workflow state and are excluded (GH #251). Not editorial doc prose,displayOrder,extRef, layout. - TestCase: scenario/Gherkin body +
testLevel+testFunctions. - Part / PartDef: ports/interface surface, not doc prose.
Resolved (§8, decision 2): v1 ships the default projection — the body plus all frontmatter except editorial/presentation fields (
name,displayOrder,extRef, the workflow-state fieldsstatus/claimedBy/claimedAt/assignedTo(GH #251;statusstays in the projection for retiring values such asdeprecated/superseded), the removedtitle,traceBaselines, and diagramlayout/shapes/edges/svgFile/pumlFile). Per-type surfaces are deferred until false positives justify them.
Hash algorithm¶
Recommend blake3 — faster than SHA-256, and truncated hex reads cleaner in frontmatter. sha2 is the alternative if FIPS familiarity matters. No cryptographic hashing exists in the tree today (only a non-stable DefaultHasher for cache filenames in remote.rs), so this adds the first real digest dependency to crates/syscribe-model/Cargo.toml.
Resolved (§8, decision 3): BLAKE3 (the
blake3crate). The sameelement_hashis reused by release baselines for their full-content seal.
Store as blake3:<hex> (algorithm-prefixed) so the digest can be migrated later without ambiguity.
4. Where the baseline lives (Axis 2)¶
Decision: on the source element, colocated with the link.
Links point upstream and the source holds the reference (§12.1), so the source also holds the baseline. Keyed by target, one hash each:
# TC-SCHED-BITMAP-001.md — a TestCase
verifies: [REQ-SCHED-BITMAP-001]
traceBaselines:
REQ-SCHED-BITMAP-001: "blake3:9f2a…"
No acceptedAt / acceptedBy¶
The hash is the state. Who re-baselined it and when is answered by git blame on the line that changed the hash — the model is git-controlled, so storing provenance in frontmatter duplicates git history and adds diff churn for no detection value. This matches how the rest of the project leans on git rather than re-recording provenance.
Consequence to accept: "who owns clearing this suspect link" is a review-process concern (git + PR review), not a field. For most safety audits the VCS is the audit trail. If a certification context ever requires provenance that outlives git — or a hash captured in another repo under multi-repo composition — add it then, not now.
New field on RawFrontmatter (crates/syscribe-model/src/element.rs:291): trace_baselines: Option<BTreeMap<String, String>> (traceBaselines), keyed by target id/qname → algorithm-prefixed hash. BTreeMap for deterministic output.
5. Propagation (Axis 3)¶
When B changes, the direct link A→B goes suspect. Does A's dependent C→A also go suspect?
Decision: implicit one-hop propagation; no eager graph flooding.
- Reviewer inspects the B change, clears A→B, and — if the change actually matters — edits A. That edit changes A's own normative projection, so C→A flips suspect on the next validation. Suspicion rides the same hash mechanism one hop at a time, gated by a real human edit. No "suspect storms," no transitive closure to maintain.
- Optionally add a
--show-affectedadvisory view that computes the transitive reachable set ("potentially affected") without flipping them suspect.
Rejected: eager transitive closure (floods the trace DAG immediately; usually noise — a downstream change need not affect grandparents).
Where it lives in code¶
The named reverse indices (verified_by, derived_children, …) are built inline on ValidationResult (crates/syscribe-model/src/validator.rs:54-79, built around validator.rs:3561-3640). The suspect pass is a natural post-pass consuming ValidationResult + Resolver. Because propagation is implicit, the pass only needs to: for each link, resolve the target, hash its projection, compare to the stored baseline.
6. Lifecycle & CLI¶
New suspect subcommand. The CLI is a hand-rolled match (not derive-clap): add ("suspect", include_str!("../../../prompts/help/suspect.md")) to HELP (crates/syscribe/src/help.rs:8) and a "suspect" => { … } arm near crates/syscribe/src/main.rs:693.
| Verb | Behavior |
|---|---|
suspect list | Report all suspect links (source, target, kind) |
suspect accept <src> <tgt> | Re-baseline: capture the target's current hash into traceBaselines |
suspect accept --all | Re-baseline every currently-suspect link (bulk review) |
suspect accept --all-unbaselined | Onboarding: baseline every link that has no baseline yet; never overwrites an existing one, so it cannot clear a suspect flag (mutually exclusive with --all) |
Validation code¶
The suspect pass emits warning W090 (stale baseline). Recorded test results use the same projection hash to notice that a result predates a change to its test or requirement (W091, REQ-TRS-RESJOIN-001); there is no accept for them, since a result is evidence of a run, not a review. It is non-fatal by default and gateable in CI with --deny W090 (exit code 2 when a suspect link exists). It is documented in prompts/help/suspect.md and the validation code reference.
A missing baseline for an existing link is a separate condition. As resolved in §8 (decision 4), it raises no code during validate — detection is opt-in and additive — and is surfaced on demand by suspect list, which reports unbaselined links alongside suspect ones. suspect accept --all-unbaselined is the one-time switch that baselines them all.
7. Integration points (summary)¶
| Concern | Location |
|---|---|
| Link fields (raw strings) | RawFrontmatter, crates/syscribe-model/src/element.rs:291+ |
New traceBaselines field | same struct |
| Resolve link target | Resolver::resolve_ref, resolver.rs:519 |
| Canonical serialization precedent | export.rs:44 (JSON + null-strip), BTreeMap at element.rs:759 |
| Reverse indices (propagation home) | ValidationResult, validator.rs:54-79, built validator.rs:3561-3640 |
| Finding / severity | Finding/Severity, validator.rs:26-40; helpers 6991-6999 |
--deny gate | parse_gate_options main.rs:182, GateOptions query.rs:1877 |
| CLI dispatch | HELP help.rs:8, match arm main.rs:693+ |
| New dep | blake3 (or sha2) → crates/syscribe-model/Cargo.toml |
| Complements (not duplicates) | RefState commit-drift, config.rs:118 |
8. Decisions (resolved)¶
- Scope — all trace-link kinds (REQ-TRS-SUS-LINKS-003):
verifies,derivedFrom,satisfies,satisfiedBy,refines,implementedBy,supertype,subsets,redefines,breakdownAdr,typedBy, and the domain links (hazardRef,mitigatedBy,supports,evidence,confirms). OnetraceBaselinesmap on the source covers every kind, keyed by target. - Projection — default projection (REQ-TRS-SUS-LINKS-002): body + normative frontmatter, excluding
name/displayOrder/extRef/title/traceBaselines, the workflow-state fieldsstatus(except retiring statuses such asdeprecated/superseded)/claimedBy/claimedAt/assignedTo(GH #251), and diagramlayout/shapes/edges/svgFile/pumlFile. Per-type surfaces deferred to a later phase. - Hash — BLAKE3, stored
blake3:<hex>. - Missing-baseline — not treated as suspect and silent during validation (opt-in/additive, REQ-TRS-SUS-LINKS-004); unbaselined links are surfaced on demand by
suspect listrather than via a distinct warning code, keepingvalidateoutput free of coverage-gap noise.suspect accept --all-unbaselinedbaselines them in one onboarding pass. - User-defined link types —
links:instances (declared in[linkTypes]) are trace links too and are baselined/flagged the same way, unless the link type setssuspect = false.
9. Phasing¶
- Done (v1):
traceBaselinesfield,blake3dependency, default projection,suspect list/accept/accept --all/accept --all-unbaselined, warningW090, all trace-link kinds (decision 1), and MCPsuspect_list/ guardedsuspect_accept. - Per-type projection specialization as false positives surface.
--show-affectedadvisory transitive view.- Multi-repo baseline provenance if a certification context demands it.