A successful analysis returns a Report from analysis_service.report. The
report keeps the result and the system model it was based on in one payload.
Code verifies that referenced element IDs exist, boundary crossings match the
embedded model, summary counts match the claims, and the framework blocks match
the job's request. A report that fails those checks cannot be constructed.
This structural consistency is useful but limited. It does not establish that the extraction or security judgement is correct. Start with Concepts for the plain-language meaning of the fields below.
Get one from either the engine (outcome.report) or the
/v1/jobs/{id}/report endpoint.
Run the web app — First-Run step 3. It renders a real report of your own: finding cards, a severity summary, the extracted DFD, and the per-node provenance panel.
There is no checked-in static sample report. The local app renders the current schema directly from a real run.
A job names the security frameworks it wants (frameworks, required and
non-empty — see HTTP-API), and the report answers exactly that
set. The envelope carries what the job and the shared system model are; each
framework's own findings sit in its own block under analyses.
class Report:
schema_version: str # "3.0"
disclaimer: str # the SERVICE's: AI-generated, not human-reviewed
job: Job # id, status="completed", timestamps, revise_rounds, frameworks
input: InputRef # system_name + one ref per submitted source
nodes: list[
NodeRun
] # per-node model, sampling fingerprint, duration_ms, token usage
sampling: dict[str, dict] # per-tier resolved decoding params (provenance)
system_model: SystemModel # the ONE canonical model every framework ran on
boundary_crossings: list[BoundaryCrossing]
shared_element_names: list[
SharedElementName
] # different-typed elements sharing one name slug
elements_analyzed: int
model_repair: ModelRepair | None # what the repair pass could change, and did
assertions: (
AssertionRecord | None
) # the assertion catalog the lanes read, and the rows refused
analysis_context: (
AnalysisContext | None
) # what informed the analysis (never what proves it)
analyses: list[FrameworkAnalysis] # one block per framework, in the job's own orderA field sits where the thing it describes sits. Ten fields describe the job
or the shared model and stayed on the envelope. Eight describe one framework's
output and moved onto the block. analysis_context split on the same rule: the
instruction digest describes the built graph and the domain packs describe the
model, so both stayed, while fired_rules and knowledge_docs name one
package's rules and what they retrieved, so both moved.
analyses is an ordered list, not a map. It is ordered by the job's own
selection, and the envelope re-checks that the blocks answer exactly the
frameworks the job named — a report that answered half a selection would make
every consumer check which half it holds.
One shared model, never a second copy of it. The alternative — one whole report per framework — was refused because it duplicates nine fields including the largest block after the findings, and nothing could check that two embedded copies of one model agreed.
class FrameworkAnalysis:
framework: str # "asvs" | "stride" — the name the job selected
framework_version: str # the ruleset version that produced these claims
disclaimer: str # the PACKAGE's: what this framework's claims assert
claims: list[RuledClaim] # the actionable findings
rejected_claims: list[RuledClaim] # the audit trail of drafts the critic ruled out
scope: list[ScopeEntry] # units this framework considered and raised nothing about
coverage: list[LaneCoverage] # per-lane account of what each agent was offered
unverified_grounds: list[UnverifiedGround]
unreconciled_rulings: list[
UnreconciledRuling
] # how the first critic pass failed, before the re-ask
repaired_quotes: list[RepairedQuote]
unresolved_mentions: list[UnresolvedMention]
unresolved_evidence: list[UnresolvedEvidence]
unknown_claim_identities: list[UnknownClaimIdentity]
dropped_claims: list[DroppedClaim]
fired_rules: list[str] # this package's deterministic rules that matched
knowledge_docs: list[str] # local-corpus documents those rules retrieved
summary: BlockSummaryclaims holds the actionable findings; rejected_claims is the audit trail.
Both are the same type — placement is decided by the verdict.
Two disclaimers, and they say different things. The envelope's is about the service; a block's is about that framework — what its claims assert. The two stop being one sentence the moment a report carries a framework that rules on requirement applicability rather than on attacks.
A block may narrow its arrays. STRIDE's block is a StrideAnalysis, whose
claims are Threats and whose summary carries by_category and
by_severity. A consumer that does not know a framework reads the base shape —
an ID, the (framework, version) pair, a title, a description, the elements and
the grounds — which is the honest outcome and what the viewer's fallback card
renders.
Claim is the neutral supertype: exactly what the service constructs on an
agent's behalf, plus what says which framework the conclusion is of. Anything
a framework judges — a category, a severity, a mitigation — belongs to that
framework's own record.
class Claim:
id: str # unique within its own block
framework: str # which framework this is a conclusion of
framework_version: str # required, non-empty
title: str
description: str
affected_element_ids: list[str] # element IDs in system_model — always resolve
grounds: list[Ground] # why it was raised — at least one, never empty
class RuledClaim(Claim):
verdict: Verdict # the critic's ruling(framework, framework_version) is one pair and both halves are required. A
framework identifier with no version is uninterpretable one release later — the
standard a claim cites may renumber every requirement between major releases, and
nothing recovers the intent from an old report.
id has no shared grammar. Each package composes its own from its own ID
rule, so it is unique within its block and says nothing across blocks: two
packages composing S-01 are naming two different claims, not colliding.
affected_element_ids may be empty on the base, because a framework can
raise a claim about a property of code rather than a position in the graph. The
referential check still runs over whatever IDs are there. STRIDE narrows it to
require at least one.
class Threat(RuledClaim):
category: StrideCategory # spoofing | tampering | repudiation | ...
severity: Severity
mitigations: list[Mitigation] # {summary, detail}
confidence: Rating # low | medium | high (critic-calibrated)Category letters: S spoofing, T tampering, R repudiation,
I information-disclosure, D denial-of-service, E elevation-of-privilege.
A threat's id carries its category letter — composed by the service from
the package's own ID rule, and stamped with the lane in the same call, so the
letter and the lane cannot disagree. Nothing re-validates the composed string:
there is no longer a pattern to check it against, because a check would hide a
bad composition rather than catch it.
StrideAnalysis adds missing_mitigations to the block, and its summary adds
by_category, by_severity and by_severity_confirmed to the neutral three
counts. The severity map is cut twice on purpose. A block's claims are the
confirmed and the needs-info together, and the rubric rates a claim resting on
an unstated control as if the control were absent — so a conditional claim can
reach the top band on a fact nobody described, and one pooled map let "three
critical" read as three settled findings. Subtract for the conditional count
rather than reading a third map that could disagree with these two.
class RequirementRuling(RuledClaim):
chapter: AsvsChapter # encoding-and-sanitization | authentication | ...One field, and nothing else. ASVS grades nothing: no severity, no confidence
and no mitigation, so the package carries no severity_rubric.md and the block's
summary adds by_chapter alone. A claim's id is the standard's own
version-safe reference — v5.0.0-1.2.5 — composed by the service from the lane's
chapter number and the <section>.<requirement> key the agent supplied, so the
chapter in the ID, in the lane and in the record cannot disagree.
affected_element_ids is empty on most ASVS claims, and that is correct
rather than a gap: the majority of the standard's requirements address a coding
practice with no position in the graph, which is why the neutral Claim allows
an empty list where STRIDE narrows it.
An ASVS claim never reports a pass. confirmed means the requirement applies
and the input does not show it satisfied; needs-info means the input does not
settle it; rejected with rejected_because of evidence means the requirement does not apply; a rejection for reasoning, lane or duplicate answers nothing about it, and the requirement stays listed in scope as not-raised. Verification needs
source code, configuration and the people who built the system, and a job here
carries prose. See
ADR 0013.
AsvsAnalysis adds one more field to the block:
level: 1 | 2 | 3 # the requirement set this run was ruled againstIt arrives from the job's own options, which the report also carries on job,
and it makes the block self-contained: a reader holding one block can tell which
requirement set produced the answer, and the block's own checks verify that every
requirement in that set appears exactly once. A level-filtered run rules on the
standard's own cumulative set for that level, and no output of one is a compliance result.
class ScopeEntry:
unit: str # the requirement, lane or unit considered
state: "not-raised" | "not-applicable" | "undecidable" | "needs-other-evidence"
reason: str # required unless applicable
needs: (
str # the kind of evidence that would settle it; set iff needs-other-evidence
)A framework whose own presence tests rule a unit out has to say so: dropping it silently leaves a reader unable to tell "considered and cleared" from "never looked". The complement is not derived — every unit appears.
Four states, and none is a weaker spelling of another.
not-applicable— the unit does not apply to a system of this shape. A finished answer, with the reason: either the framework's Precondition refused the model, or the package's own rules ruled the unit out before its lane ran. A draft the lane files on such a unit anyway is refused at the fan-in and listed indropped_claims. No shipped package rules a unit out this way. ASVS did, from vocabulary, and stopped: absence of a word is not absence of the thing (ADR 0027). The hook remains for a package that can refute a unit from a stated fact.undecidable— the framework's Precondition could not tell whether the framework applies to this system at all, so no lane ran. The remedy is more input, which is why it is not folded intonot-applicable.not-raised— no lane filed a claim on the unit. A fact about the output, not a verdict that the unit applies: the lanes were handed the unit and filed nothing, and nothing in the report certifies that they weighed it.needs-other-evidence— the unit applies, a lane raised it, and the service withheld the claim because settling it needs evidence of a kind the job does not carry:code,configorpeople, where a job carriesprose. The kind sits inneedsas a field rather than a phrase inreason, so a reader can group by it. The answer is actionable by supplying that kind of input.
Every state but not-raised must state a reason, which is the rule Verdict
already applies to its two non-confirmed states. needs is set if and only if
the state is needs-other-evidence.
The unit is the framework's own. The neutral answer is the lane, because a lane is the only unit the service knows without reading a catalog it does not own. A package that holds one answers in its own units instead: an ASVS block lists requirement identifiers, which at level 1 is 70 entries and at level 3 is 345. Every requirement in the selected level is either a claim in the block or an entry here.
A framework whose Precondition refuses the system fills this list and nothing
else. Its block carries no claims and no coverage, and every unit appears here in
the precondition's own state with its reason: not-applicable when the
framework does not apply to a system of this shape, undecidable when the input
never said. The two stay apart as states, not only as reasons, because the
remedy differs. A refusal is not a job failure — a job naming two frameworks, one
of them refused, still carries the other's analysis.
How schema_version moves. Adding a field is a minor bump: a consumer
reading the fields it already knows is unaffected. Changing the meaning or the
spelling of something that already exists — renaming a field, renaming a value
inside an existing enum, or narrowing what a field may contain — is a major
bump, because that is the change a consumer cannot detect by reading its own
fields and will otherwise misread silently. One bump covers a whole cutover,
however many changes it carries.
There is no compatibility adapter, and that is the policy rather than an
omission. A major bump is a hard cutover: Report keeps extra="forbid", so
each version's shape refuses the other's payload and a consumer learns at the
first parse rather than from a field that quietly means something new. An
adapter would turn that refusal into a translation, and a translated report
carries guarantees it was never written under. So an old report is readable by
the version that wrote it and by nothing else, and a reader who needs both keeps
both.
That is also the whole answer for the assertion layer. It arrived inside 3.0,
3.0 has never shipped, and so no report exists that predates it — assertions: null means a deployment that ran no pass, never a file older than the field.
schema_version2.0 addedgroundson every threat andunverified_groundson the report — both additive, and minor on their own. What earns the major is thatnodes[].nodechanged the values it carries: the six category nodes are nowanalyze_<category>, notanalyst_<category>. A consumer keying onanalyst_spoofingdoes not error; it matches nothing, silently.
InputRef ties the report to what was submitted without carrying the text:
class SourceRef:
kind: str # description | transcript
label: str # the citation key an element's source_label names
sha256: str # digest of that one source's text
class InputRef:
system_name: str
sources: list[SourceRef] # one per submitted source, in submitted order
source_sha256: str # aggregate, taken over the refs aboveThe aggregate is computed over the refs, not over the concatenated text, so it stays recomputable from the report alone — the refs are in the report, the text never is.
Every element in system_model carries source_excerpt (a verbatim quote),
source_label (which source the quote came from — it always names one of the
refs above), and an optional source_speaker (who said it, where the text
attributes it). source_speaker is a separate field precisely so a name can be
stripped; a name inside a verbatim excerpt cannot be. An element's notes may
also quote submitted words — a speaker's hedge, or two sources disagreeing —
so both fields ship raw caller text in the report JSON, as does every quote
ground on a claim.
Every threat carries at least one Ground: the evidence the category agent that
drafted it says justifies it. grounds is never empty, and has no maximum.
Chosen by an agent, constructed by the service. A category agent names the
facts it relied on — an ID from the evidence catalog the service derives from
the validated model, or a quoted span and the source it came from — and the
service builds these records from that selection. No model writes a Ground,
which is why the flat encoding below is safe: the conditional relationship
between kind and the fields it requires never has to survive a schema
compiler. See ADR 0004.
class Ground:
kind: (
"quote"
| "unknown-attribute"
| "absent-attribute"
| "derived-fact"
| "absent-element"
| "assertion"
)
text: str # quote: the verbatim span, ≤1000 chars
source_label: str # quote: names one of input.sources
element_id: str # either attribute kind: resolves in system_model
attribute: str # either attribute kind: the attribute relied on
flow_id: str # derived-fact: a data flow in system_model
term: str # absent-element: a word no element's text names
assertion: str # assertion: a row's identity in assertions.catalogkind |
Carries | Reads as |
|---|---|---|
quote |
text + source_label |
the submitter's own words said this |
unknown-attribute |
element_id + attribute |
this fact was never stated, so the threat stands unrefuted |
absent-attribute |
element_id + attribute |
the input states this control is not there |
derived-fact |
flow_id |
this flow's boundary crossing is the fact relied on |
absent-element |
term |
no element's text names this thing |
assertion |
assertion |
the sources state this fact, which the model has no field for |
unknown-assertion |
assertion |
the sources were asked this and left it open, and the model has no field for it |
What the assertion pass reached. A reader of assertions has no way to tell
a system with few stated facts from a pass that asked few questions, and the two
look identical. Report.assertion_coverage answers it, derived from the rows and
the model rather than stored beside them: reachable is the element attributes
some predicate could speak to on this model, settled is how many the catalog
answers, open counts the questions the sources raised and left unanswered, and
own_subjects counts the facts about a principal or a credential that the graph
has no place for at all. None on a job that ran no pass.
It is a count and not a score. Absence from the catalog is never an absent control — it is a predicate nobody asked about — and this is what lets a reader see how much was asked.
An assertion ground is a reference on the same terms as a derived-fact: it
names a row in assertions.catalog by the row's computed identity, and the
row's subject, predicate, value, basis, scope and support spans are read there.
On load the report checks the row is one the catalog settles — it holds a
value rather than unknown, its basis is support of some kind, no assessment
set it aside, and no other row disagrees at its subject, predicate and scope —
and refuses the report otherwise. A report whose assertions is None settles
nothing, so a claim citing one is refused the way a claim citing a crossing the
model does not derive is.
One flat model, not a discriminated union. Fields belonging to the other
kinds are empty strings, and a record carrying a field its own kind does not
claim is rejected on construction rather than tolerated — so a consumer may read
the fields its kind names and ignore the rest. Why the shape is flat rather
than a tagged union, which it should be, is
ADR 0002.
The two attribute kinds carry identical fields and different facts, so a
consumer must switch on kind rather than on which fields are populated.
unknown-attribute is a question the submission left open — the threat resting
on it is conditional and typically routes to needs-info. absent-attribute
is the submission answering that question with no: authentication: "none; accepted by network position". Folding the two would report a control the
input described as missing as a gap in the description
(ADR 0012).
A derived-fact names the flow and never copies the zones it crosses: they
recompute from boundary_crossings, which the report already carries, so a
renderer resolves them locally and there is no second copy to disagree with the
first. A crossing there carries assumed_endpoints as well as its two zones —
the endpoints whose trust_zone extraction inferred rather than read, empty
when the input stated both. It is derived from the embedded model's own
assumptions list, so the envelope check that the crossings are the derived
ones covers it too
(ADR 0032). A quote's source_label always names one of the SourceRefs in
input.sources — but the report never carries the source text, so a quote is
checkable against its origin only by whoever holds the submission.
The service does that check itself, at merge time, against the bytes that were
submitted: a quote is matched to the source it names through a fixed ladder
(whitespace runs collapse, typography and case fold, … marks an elision,
inline markdown is ignored). It proves a quote is present, never that it
supports the finding — that judgement stays the critic's.
class UnverifiedGround:
claim_id: str # the claim carrying it, unique within this block
index: int # position in that claim's `grounds` list
reason: strA quote that could not be found is marked, not removed: the entry still
renders, and unverified_grounds points at it by claim and index. A claim
where nothing verified is dropped and recorded in
dropped_claims
instead. So an entry in this list means "one citation on an otherwise justified
finding did not match", which is worth showing a reader and is not grounds for
hiding the finding. The list is also empty when no source text was available to
check against, so it is evidence of a failed check, never of a check having
run.
class UnresolvedEvidence:
claim_id: str # the claim that cited it
reference: str # the reference as written, e.g. "unknown:flow:ghost:authentication"The evidence catalog is closed and
derived, so a reference outside it names no fact and no ground can be built
from it. That is what separates this from unverified_grounds: an unverified
quote is a real ground whose text the service could not find, and it still
renders. Here there is nothing to render, so the entry is dropped and this mark
is the only trace.
Marked per reference, dropped per claim. A threat citing three facts, one
of them composed, is still justified by the two that resolve. A threat whose
evidence resolves to nothing at all has no justification left and is dropped
into dropped_claims,
because grounds is min_length=1 and a finding resting on nothing is what
this schema refuses to represent. A dropped claim gets no entry here: this list
names a claim the block carries, and the groundless mark names the references
instead.
This narrowed a whole-job failure in 2.9. Agents compose well-formed references
— correct grammar, plausible element IDs, absent from the set — and a live sweep
lost 2 of 12 jobs to it. Discarding six lanes of analysis because one threat
named one fact that does not exist is the trade unresolved_mentions already
refused to make. See ADR 0009.
A consumer that read "the job returned" as "every citation resolved" was relying on an absence; this list is where that guarantee now lives.
class RepairedQuote:
claim_id: str # the claim carrying it
index: int # position in that claim's `grounds` list
written: str # what the agent wrote
similarity: float # the ratio that licensed the replacement
moved: list[str] # what the span changed: "negation", "number", or nothing
scan_complete: bool | None # did the rung rank every window; None if not recordedThe ladder refused what the agent wrote, and the repair rung
(analysis_service.grounding.repair_quote) found a window of the named source
near enough to hand back. The ground now carries that window — the submitter's
words, whitespace collapsed — and this mark carries the agent's, so the
substitution is on the record. A repaired quote verifies, so an entry here is
never also in unverified_grounds.
The rung never accepts the agent's words: it replaces them. What can go wrong
is a replacement that says something different from what the claim rests on.
moved is the mechanical part of that: the negation tokens and the number
tokens of the two texts, compared as multisets, so a span that put a "not"
back or changed a figure is named as such. It is computed from written and
the ground's text and checked against them on load. similarity says how
near the span was and never whether it supports the claim. The critic reads
the replaced span with written and moved beside it, and the viewer shows
the difference to a reader. scan_complete is False where the work budget
or a deadline stopped the scan before every window was ranked, so the span is
the best found by then. See ADR 0018.
class DroppedClaim:
claim_id: str # the claim the service dropped
title: str # what the agent called the finding
reason: str # which fault, in the agent's own wordsOne list for every reason a claim is dropped. reason says which, bounded to
500 characters, and repeats what was wrong — the quote not found, the references
not held, the element IDs not in the model, the schema error — because nothing
else persists a draft:
- the proposal failed its own schema: a verb outside the closed set, a severity value the enum does not hold, neither a reference nor a quote;
- every reference it cited is outside the catalog;
- its only grounds are quotes the source it names does not contain, or names a source the job does not carry;
- every element it named in
affected_element_idsis absent from the model; - its ID repeats an earlier draft's in the same framework.
Like unknown_claim_identities, the claim_id is deliberately absent from
claims and rejected_claims, and title is the only trace of what was found.
A proposal whose ID key was unreadable is keyed <framework>:<lane>:proposal-<n>.
Each of these used to fail the whole job. See ADR 0017 and ADR 0019.
class UnresolvedReference:
claim_id: str # the claim that named it
element_id: str # the ID as written, e.g. "process:web-api"
reason: str # empty: not in the model; otherwise why an existing ID was droppedThe structural twin of unresolved_mentions: an ID in affected_element_ids
rather than in prose. The reference is dropped from the claim and recorded here,
and the claim stands on the elements that resolved. A claim that named elements
and lost every one is in dropped_claims instead.
reason is empty when the model does not contain the ID. It reads more than two hops from every place the claim's grounds name when the ID exists and the
claim's own grounds do not reach it. One hop takes a cited flow to its two
endpoints, and a cited element to its flows and their far ends; the bound is
two such hops, and nothing further. A
claim resting on quotes alone is bounded by the IDs its own description cites,
with no hop. Reach belongs in the description; affected_element_ids is what
the action lands on.
A threat names the elements it acts on twice: structurally in
affected_element_ids, and in prose inside description, which the analyze
prompt asks agents to write with element and flow IDs cited inline. The two are
checked differently on purpose.
class UnresolvedMention:
claim_id: str # the claim whose description cites it
mention: str # the ID as written, e.g. "process:web-api"A structural reference that does not resolve is dropped from the claim and
recorded in unresolved_references; a claim left naming nothing is dropped. The
same ID written into the argument is marked here, for the reason an
unfindable quote is: merge has no re-ask path, and a whole report is too much to
trade for a mistyped ID in a sentence.
The mark is worth more than typo-catching. The analyze prompt is built around a
single worked exemplar system and spends a section telling the agent never to
cite that system's IDs. A description arguing about process:web-api in a job
whose model has no such element is that contamination reaching the one field a
reader reads as ordinary analysis — and nothing checked it before 2.2.
Detection is deliberately narrow: a token has to open with one of the five real
type prefixes and a colon, a shape ordinary English does not produce
("Process: the web app" has a space and is not a mention). It errs toward
missing a citation rather than flagging prose, which is the right way round for
a mark that annotates a finding a human will read.
mitigations may be empty, but the prompt licenses that for exactly one case:
the threat is conditional on an unknown, and no countermeasure can be named
without first learning that fact.
class MissingMitigation:
claim_id: str # the threat offering no countermeasureThat case is mechanically recognizable, so the report distinguishes it. A
threat triggered by an unknown carries an unknown-attribute ground —
the trigger dictates the branch — so an empty mitigations list with such a
ground is the licensed case and is not marked. An empty list without one is,
and an absent-attribute ground does not license one: the fact is already in
hand, so a countermeasure can always be named.
This is a completeness signal, not a correctness one, which is why it is a mark rather than a failure: a finding with no recommended action is still a finding. What it buys a reader is the ability to see which findings arrived with nothing to do about them, and to tell those apart from the ones that correctly said "answer this unknown first".
An element ID is type:name-slug, so a model that records one real thing twice
— once as process:web-app, once as store:web-app — holds two distinct
IDs. The validity gate's duplicate-id rule compares whole IDs and passes the
pair. extract.md tells the transcriber "nothing gets two types", and this is
what that rule is about.
class SharedElementName:
name_slug: str # the slug both elements normalize to
element_ids: list[str] # the two or more IDs that share itWhy a mark and not a gate failure. The gate has exactly one severity —
every ValidationIssue is fatal, and an empty list is the definition of
ready-for-analysis the graph routes on — and this does not deserve that,
because it is not always wrong. A system can legitimately run a process and
keep a store of the same name. Failing extraction on the pair would reject a
valid model, and would spend repair's single pass telling a transcriber to
rename something correct. So it is reported to the reader and to nobody else:
it never reaches repair, which is told to change nothing the issues do not
cite.
Only zoned elements are compared — external entities, processes and data
stores. A trust boundary is a zone rather than a thing in the system, and a
flow's ID is built from its endpoints rather than from a bare type-and-name
pair. Same-type collisions never appear here: two elements of one type sharing
a name hold the same ID, which is duplicate-id's to report.
This is the first mark about the model rather than about the threats, which
is why it carries element IDs and no claim_id — and why it sits on the
envelope beside the model it describes rather than on any block. It is
recomputable from the
report's own embedded system_model, by design.
class ModelRepair:
scope: str # "elements" or "whole"
implicated: list[str] # the elements the validation issues named
restored: list[str] # elements the repair changed anyway, put backNone where no repair pass ran. The repair prompt asks for every element
the issues did not name to come back byte-identical; the revalidate gate
enforces it. When every issue named an element, scope is elements: the
repair may change those elements and add elements, and every other element is
restored from the model the issues were computed against, with its ID in
restored. When an issue named no element, a fault over the whole object,
scope is whole and nothing is restored, because no narrower patch could
answer it. A report whose nodes name the repair node and carry None here
predates the field.
A claim count says how much a lane agent found. It cannot say whether a lane
that found nothing had examined the system and cleared it, or had never looked
at half of it. coverage carries one row per lane — every lane the package
declares, including one that filed nothing. A lane is a package's own unit, so
for STRIDE that is the six categories; the row is keyed by the lane slug rather
than by a category, because the six are one package's lane list rather than a
fact about the report.
class LaneCoverage:
lane: str # the lane slug the package declares
drafts: int # claims this lane filed, before the critic ruled
rules: int # deterministic triggers defined in this lane
rules_fired: int # of those, how many produced a candidate here
candidates: int # structural leads handed to this agent
candidates_cited: int # leads whose every element one draft cites
elements: int # elements in the system model
elements_cited: int
boundary_crossings: int
boundary_crossings_cited: int
unknown_controls: int # attributes stating no verified control
unknown_controls_cited: int # of those, the pairs some draft grounds onTwo of the *_cited halves are counted at the grain their names promise.
unknown_controls_cited credits an (element, attribute) pair only where a
draft carries an attribute ground naming exactly that pair, so citing a store
does not credit every unstated control it holds. candidates_cited credits a
lead only where one draft cites every element the lead names, so two drafts
that between them mention a flow's two endpoints have not taken up the lead.
Every number is computed in code, from the system model, the deterministic candidate triggers and the agent's own drafts. None of it is asserted by a model, which is the reason it is worth recording at all.
Read *_cited as cited, not as considered. An agent that examined a flow
and correctly concluded there was no threat cites nothing, and is
indistinguishable here from one that never read it — there is no observable
that separates them, since a model's own claim to have looked is exactly the
assertion this design declines to trust. The fields are named for what they
measure. The honest use is the aggregate: a category citing two of forty
structural leads across a corpus is a signal, one agent's zero on one case is
not.
Counted over the drafts, not the ruled claims: coverage is a fact about what the lane agents did with the system, and a draft the critic later rejects was still part of the system being examined.
class AnalysisContext: # on the ENVELOPE
instruction_sha256: str # digest of every LLM node's composed instruction
domain_packs: list[str] # the reference packs this model earned, in selection orderTwo more fields answer the same question per framework, and sit on the block for the reason the split gives above — they name one package's own rules:
fired_rules: list[str] # this package's deterministic rules that matched, sorted
knowledge_docs: list[str] # local-corpus documents those rules retrievedThe report records what each node ran on (nodes, sampling) and what each
finding rests on (grounds). This is the third thing: what the service put in
front of the agents.
instruction_sha256digests every LLM node's composed instruction with the{placeholders}still unexpanded, so it identifies the repo-authored text — prompts, category skills, the shared rubric — and carries no submitted bytes. The submission's own digest isinput.source_sha256and stays separate, because "which instructions ran" and "which text was analysed" are different questions. This digest is one of the seven parts of the Execution Identity, so a prompt edit moves every fingerprint the run produced, and this field is what lets a reader recompute one and see which instruction set stood behind it.domain_packsare selected per job from the model's own technology fields, so the same deployment gives two submissions different reference material.fired_rulesnames the deterministic triggers that matched, wherecoveragecounts them.knowledge_docsnames what those rules retrieved from the local corpus —notes/<id>for reference material,cases/<id>for a worked judgement, unioned across that framework's lanes. A note and a case live inside the package whose rules select them, while a domain pack stays shared, because the retrieval key decides the home. Retrieval is local and deterministic, so this list plus the checkout reproduces exactly the text the agents were shown. See ADR 0008 and ADR 0011.
None of it is evidence, and the separation is the point. A pack named here
did not ground anything and a rule named here did not find anything; what
supports a finding is its grounds, unchanged. Reference material informs
reasoning and is citable by nothing — a reader treating an entry here as support
for a threat has it exactly backwards.
null on a report from a runner that composes no analysis, which is the same
absence an empty coverage records.
Threat IDs are asked to run 01..N within each category. A lane that emits
S-01, S-02, S-05 has drifted from that, and the service logs it and changes
nothing — the IDs are unique, their letters match, and every downstream check
passes, so a gap costs a reader nothing and renumbering would move a finding's
identity between two runs of the same input. It is a fact about the agents, and
it lives in the run's logs rather than in this payload.
Two things grounds is deliberately not:
- Not an element's
source_excerpt. An excerpt says why an element is in the model; a ground says why a threat was raised. They quote the same submission and answer different questions, and the category agents cannot see the excerpts — those fields are stripped from the model rendered to them. - Not
verdict.related_unknowns. Anunknown-attributeground is backward-looking (the unknown that prompted the finding) and agent-authored;related_unknownsis forward-looking (the unknown that must be answered before the finding can be ruled on) and critic-authored. When they name the same attribute, that is agreement, not duplication.
related_unknowns holds at least one entry, in one of two spellings. Which one
is true of the fact, never a matter of taste:
class UnknownRef:
element_id: str # with attribute: the fact has a place in the model
attribute: str
subject: str # alone: the fact has no place in the modelThe element spelling points at a field a submitter can fill in, and is the ordinary case for a claim about a specific element.
The subject spelling carries the question itself, for a fact the System
Model holds no slot for — whether a policy is documented, or what code does.
A framework ruling on requirements answers mostly that kind, so with only the
first spelling available its commonest verdict was inexpressible.
A reader should treat an entry pointing at an attribute that exists on every
element type — notes, say — with suspicion. Before the second spelling
existed, that was the only legal way to express a question about something the
model does not describe, and it passes every check while saying nothing.
How the first critic pass failed to reconcile with its drafts, one entry per problem, before the bounded re-ask repaired it. Empty means the first pass was clean, which is the reading that matters: a run that repaired itself is a successful run but not a clean one, and the two were previously indistinguishable in every artifact the service keeps.
A framework whose first pass never reconciles is running on its single retry. That is worth knowing from a report rather than from a live run.
class UnreconciledRuling:
claim_id: str # the claim the problem is about
kind: str # which check found it, from the closed set below
message: str # the sentence the re-ask was asked to fix it byThe nine kinds. Each is a check the service distinguishes in code, except confirmed-on-unknown, which only archived reports carry:
kind |
What went wrong |
|---|---|
dropped |
The critic returned no ruling on a draft it was shown. |
invented |
It ruled on an ID no lane agent drafted. |
duplicate-id |
One ID carries more than one ruling. |
confirmed-on-unknown |
Archived reports only. A draft whose own grounds cite an unknown was ruled confirmed. Code decides the status and the critic writes none, so no review writes this kind. |
dismissal-off-grounds |
A ruling names a fact in immaterial_unknowns that the draft's own grounds do not cite. |
duplicate-on-unit |
A draft naming a catalog unit was rejected as a duplicate, which that framework decides by identifier first. |
verdict-shape |
A rejection also names open facts, or a rejection or needs-info states no reason. |
unresolved-unknown |
A needs-info names an element or attribute the model does not hold, or names nothing at all. |
unbriefed-change |
The re-ask changed a ruling no problem named; the first pass's ruling was kept. |
Read kind, never the sentence. unbriefed-change comes from the second
look and the others from the first pass, and one ruling can produce more than
one entry — so a consumer counting causes reads the field, and a consumer
counting distinct rulings reads claim_id.
A claim_id here need not be in the block. An invented entry names an ID
no lane drafted, and a dropped entry names one the re-ask may have ruled
correctly afterwards.
Severity carries a qualitative likelihood and impact (low|medium|high);
level (low|medium|high|critical) is computed from a fixed matrix, so a model
cannot inflate it. justification explains the two inputs.
from analysis_service import derive_severity_level
derive_severity_level("high", "high") # "critical"
derive_severity_level("medium", "low") # "low"The critic's ruling on the threat:
class Verdict:
status: "confirmed" | "needs-info" | "rejected"
reason: str # required unless confirmed
related_unknowns: list[UnknownRef] # required iff needs-infoconfirmed— grounded in the model's facts.needs-info— plausible but hinges on anunknownattribute; each such attribute is named inrelated_unknowns({element_id, attribute}). Stays in its block'sclaims.rejected— not grounded. Moves torejected_claims.
The service owns the shape; each package owns the question. The three states
and the rules binding their fields are the service's, and every framework's
claims are ruled by its own critic — but what a state asserts is the
framework's. For STRIDE, confirmed means the threat holds. A framework that
rules on requirement applicability rather than on attacks uses the same three
words to answer a different question, which is why one shared rubric cannot
serve both.
Those three conditional rules hold on every verdict in a report, but they are not enforced on the critic's output — that shape is checked at the review seam, where a violation routes to a bounded re-ask instead of failing the job. See ADR 0005.
Counts a UI can render without walking a block's claim list:
class BlockSummary: # on every framework block
claim_count: int
needs_info_count: int
rejected_count: int
class StrideSummary(BlockSummary): # what STRIDE's block carries
by_category: dict[StrideCategory, int]
by_severity: dict[SeverityLevel, int] # every claim, both verdicts
by_severity_confirmed: dict[SeverityLevel, int] # of those, the confirmedsummary is computed from its own block's contents and must match them, so it
is safe to trust without recounting — a mismatched summary does not validate.
elements_analyzed is on the envelope, not here: it counts the one shared
model every framework ran against, so a per-block copy would be N copies of one
number.
Every hash in this schema is unkeyed. input.source_sha256,
analysis_context.instruction_sha256 and each node's execution_fingerprint
let a reader recompute a value and notice when the two disagree — and anyone who
edits the report recomputes them too. They establish internal consistency, and
nothing about who produced the report.
A detached signature answers that separately. See Report attestations. A valid signature says this report came from that deployment and no covered byte has moved; it says nothing about whether the findings are correct, and nothing about whether the run was certified.
The report records the configured and provider-reported model identifiers, the resolved decoding parameters, and related run metadata. These values let a consumer audit the generation setup without an outside log. They are not a complete causal record of the output: input and instruction identity are stored in separate fields, and provider behavior remains probabilistic.
class NodeRun:
node: (
str # graph node name: "extract", "analyze_stride_spoofing", "critic_stride", …
)
model: str | None # the SERVED build, vendor-prefixed; None for code-only nodes
requested_model: str | None # the CONFIGURED route this node asked for
instruction_sha256: (
str | None
) # 64-hex digest of the instructions the graph this node ran in carried
execution_fingerprint: str | None # 64-hex hash of the execution identity
duration_ms: int
usage: TokenUsage | None # what the provider says the call cost; None if unmetered
attempts: int # provider calls this execution took; 1 unless the driver retried
served_trust: (
Literal["provider_reported", "requested_echo"] | None
) # what `model` is worth as evidence; None for code-only nodes
class TokenUsage:
prompt_tokens: int
cached_prompt_tokens: int # the part of prompt_tokens served from cache
completion_tokens: int
reasoning_tokens: int # spent against max_output_tokens, absent from the output
total_tokens: int-
samplinglists the decoding parameters each tier actually used, once per tier:{"base": {"temperature": null, "max_output_tokens": 16384, ...}, "strong": {...}}. A parameter left to the model's default shows asnull, which is what the shippedtemperaturelooks like here. Values are whatever the param resolves to — a number, a count, a boolean, or a string forthinking, whose values are"low","medium"and"high"— so a consumer reading this block must not assume numbers. -
modelis what answered — the build the provider reported, joined to its vendor prefix, e.g.vertex_ai/gemini-2.5-pro-002. -
requested_modelis what was asked for — the configured route, e.g.vertex_ai/gemini-2.5-pro. Gemini is the example because one had to be, and because it is the one profiled family whose two values differ. On Claude, both fields hold the same string. -
instruction_sha256is the digest of every instruction the graph this node ran in carried, with the job-varying placeholders unexpanded. It contains no submitter bytes. On a report it repeatsanalysis_context.instruction_sha256, because a report holds one graph — but aNodeRunalso travels alone in an eval sweep that folds several graphs into one list, and there the node's own copy is the only thing that says which instruction set produced its hash. -
execution_fingerprintis a hash of the versioned execution identity: the requested route, the served route, the tier's decoding parameters, the instruction digest, and the installed versions of the distributions between the node and its provider. One value identifying everything that decided what this call could answer. It can be recomputed from the artifact alone — the two routes and the digest are on the node, the parameters are insampling, and the build versions are inexecution. It does not hash the input or the output. Code-only nodes (likeassemble) have all four asnull.Both routes are in it because the served one is the provider's claim and nothing verifies it. With the served build alone, a compromised translator could name a build the deployment already blesses and certify whatever it liked; with the pair, the requested half comes from the deployment's own configuration and the translator has no say in it.
-
executionis one block per report:identity_version(the schema the fingerprints hash) andbuild(the version of each distribution between a node and its provider). What a served build is worth is not here: it varies by vendor, and a deployment may select a different vendor per tier, so it rides on each node asnodes[].served_trust. It also carriesreview_independence— how far this deployment required each framework's critic to sit from the analysis it checks, so a reader of asharedrun sees the review was same-domain rather than inferring it from two node rows naming one model. That field is a statement and never a warning: a deployment whose selections do not satisfy its own policy fails to load, so no report exists to warn on. It isnullonly on a report with no LLM provenance at all. And it carriesextraction_format— which transportextractwrote in,fullorcompact-v4. The two produce the sameSystemModelby construction, so a reader cannot tell them apart from the report's own model; this is the field that says. It isnullwhen the run extracted nothing. Beside it,extraction_strategysays which order that node read in —graph-firstwrites aSystemModelin one pass,facts-firstwrites a Source Fact Bundle that code resolves into one, andfacts-splitwrites that bundle across two calls, one naming what the sources hold and one stating what they say about it. The two also produce the same shape, so the same reasoning applies, and it isnullunder the same condition. -
usageis what the provider reported the call cost, in vendor-neutral field names.nullfor code-only nodes, and for any LLM node whose provider metered nothing — an unmeasured call is never recorded as a free one. Nothing here is derived:total_tokensis recorded rather than summed, and the parts are not cross-checked, because vendors disagree on whetherreasoning_tokenssits insidecompletion_tokensor beside it. Sum them yourself only if you know who answered. -
served_trustsays what this node'smodelis worth as evidence.provider_reportedmeans the translator read the build's name out of the response body, so the provider named what answered.requested_echomeans the translator filled it from the request, so the served half repeats the requested half and adds nothing. It is per node rather than per report, because a deployment may select a different vendor per tier and one report can then hold rows with different answers. It is derived fromrequested_model, so a value you edit in a stored file is recomputed on read. -
attemptsis how many provider calls the node took, as the retry driver counted them.usagemeters only the call that answered; a failed attempt reports nothing. The budget settlement charges each earlier attempt the answering call'sprompt_tokens, because that is the prompt every attempt sent.
schema_version1.1 addedrequested_modeland redefinedmodelas the served build rather than the configured string. A consumer keying onmodelnow reads what answered.
schema_version2.1 addednodes[].usage. Purely additive; a 2.0 consumer that ignores unknown fields is unaffected.
schema_version2.2 addedunresolved_mentions. Purely additive — a new optional top-level list of the same shapeunverified_groundsalready had — so a 2.1 consumer that ignores unknown fields is unaffected.
schema_version2.3 addedmissing_mitigations, a third optional top-level list of service-owned marks. Additive on the same argument.
schema_version2.4 addedcoverage. Additive again: a fourth optional top-level list, service-owned and computed in code.
schema_version2.5 addedshared_element_names, a fifth optional top-level list of service-owned marks. Additive on the same argument — minor although it is the first mark describing the model rather than the threats, since what a consumer must do with an unknown field does not depend on what the field describes.
schema_version2.6 widened the values thesamplingblock can carry to every type a resolved decoding param holds, which includesthinking's enum string. No field was added, removed or renamed, and nothing a 2.5 consumer already parses changed meaning — the block was typed to numbers, so a report carrying a string here could never be produced. The first entry in this list that is a fix rather than an addition: a deployment that setthinkingran its whole graph and then failed to assemble a report.
schema_version2.7 addedanalysis_context: the instruction digest, the domain packs the job's model earned, and the deterministic rules that fired. Optional, service-owned and computed in code — additive on the same argument as the four lists before it, and the first block describing what informed the analysis rather than what it found.
schema_version2.9 addedunresolved_evidence, a sixth optional list of service-owned marks. Additive by the same rule as the marks before it. What moved beside it is a behaviour: an evidence reference the catalog does not hold used to fail the whole job, and is now dropped and marked, with only a groundless threat still failing.
schema_version2.8 addedknowledge_docsto that block: the local-corpus notes and cases the fired rules retrieved for the agents. Additive and service-owned like the rest of it, and under the same rule — a document informed the analysis and grounds nothing.
schema_version3.0 is the framework cutover, and it is major on every count the rule names: fields move, a field changes its spelling, and one changes what it carries.
threatsand seven other top-level fields becameanalyses[].claimsand their per-framework siblings. A consumer readingreport.threatsreads nothing.- The four mark classes renamed
threat_idtoclaim_id.coverage[].categorybecamecoverage[].lane, andCategoryCoveragebecameLaneCoverage.- Every claim gained the required
(framework, framework_version)pair, andSummarysplit into a neutralBlockSummaryper block withelements_analyzedleft on the envelope.- The
StrideReporttype is nowReport, andanalysesis an ordered list rather than a map.- Each block carries
unknown_claim_identitiesanddropped_claims, two further lists of service-owned marks. Each records a claim the service dropped, so unlike the marks before them theirclaim_idnames no claim in the block. What moved besidedropped_claimsis a behaviour: a claim that lost every ground, every element, its ID to an earlier draft, or its own schema used to fail the job.unresolved_referencesrecords an element ID dropped fromaffected_element_ids.- Each block carries
repaired_quotes. A quote ground'stextis no longer always what the agent wrote: where the ladder refused it and the source held a near span, the text is that span and this list carries the agent's.- The envelope carries
model_repair: what the repair pass was allowed to change and which elements it changed anyway and had put back.Nonewhere no repair ran, and on archived runs that did.repaired_quotes[]carriesmoved, what the substitution changed in the claim's own terms, andscan_complete, whether the rung ranked every window.movedis required and was filled in on the 18 archived repairs by recomputation;scan_completeisNoneon them.coverage[].unknown_controls_citedcounts a control only where a draft's attribute ground names that element and that attribute, andcoverage[].candidates_citedcounts a lead only where one draft cites every element it names. Both used to be credited from a union of what the lane's drafts cited, and archived rows carry those larger numbers.There is no version gate and none is needed.
Reportforbids unknown fields, so a 2.10 payload carryingthreatsat the top level is refused by this model, and a 3.0 payload carryinganalysesis refused by the old one. The no-shim behaviour falls out of the shapes rather than out of anything readingschema_version.
schema_version3.0 also carries a seventhGroundkind,unknown-assertion: a row of the assertion catalog whose value is the unknown sentinel, for a predicate the model has no field for. It is the assertion layer'sunknown-attribute, and it exists for the same reason — an element attribute nobody stated is offered as a fact an agent may raise a conditional claim on, and a predicate with no graph field had no such offer, so a question the sources explicitly left open reached nobody. A separate kind rather than a flag onassertion, because a settled row is a fact in hand and an open row is a question: the fields are identical and the fact is not.CONDITIONAL_GROUNDSis the one reader of which kinds make a claim conditional, and bothunknownkinds are in it.
schema_version3.0 also carriesassertions[].projection_version: which rules turned the catalog's rows into graph attributes. Its own number rather than the catalog'sregistry_version, because the projection's loss rules live inprojectand have already changed once with no registry change beside them. It is what lets a loaded report be re-checked for contradictions against the rules that produced it, and a record from another version is left alone rather than re-read under rules it never ran. The same release adds a sixth thingassertions.issuescan say,graph-contradiction: the one code there that refuses no row — it reports that a graph attribute and the rows authoritative for it state opposite things, which is the defect the layer was built to make visible.
schema_version3.0 also carriesassertionson the envelope: the assertion catalog the job's lanes selected from, with each row's basis and assessment, and the rows the resolver and the gate refused.Noneon a job that ran no assertion pass, which is every job on a deployment that has not setANALYSIS_ASSERTIONS. A sixthGroundkind,assertion, references a row in it. Additive on the same argument asmodel_repair, and it rides 3.0 for the reasonabsent-attributedoes: 3.0 has never shipped.
schema_version2.10 corrected whatcoverage[].elements_citedcounts, and holds every*_citedhalf to the total beside it. The definition above is unchanged; the computation counted prose citations raw, so an ID a description named that the model does not contain was counted as a cited element and the numerator could exceed its denominator. Minor by the rule above — no field is added, removed or renamed, and none changes meaning. Read it anyway if you stored rows: one carrying more cited than offered no longer validates, and a citation rate you computed off such a row was above 1.0 and is now correct.
The report records both model fields and compares neither. It doesn't need to: if the build moves, the fingerprint moves with it, and the run stops matching any list of blessed fingerprints — so the drift surfaces there rather than through comparison logic here.
The fingerprint is computed per node execution. One analysis cannot loop, so a node appears once in it; across an eval sweep a node appears once per case, and a build that moved partway through gives that one node two different fingerprints. That is the signal, not a defect.
The fingerprint makes model-and-sampling drift visible; it does not make the
result reproducible. seed is best-effort, and some vendors do not accept it.
Whether the observed fingerprints match a baseline that a particular
deployment has blessed is a separate question, answered against that
deployment's config/blessed-fingerprints.toml and recorded on the job—never
on the report. See
Architecture → Provenance and certification.
A report produced without live models (the in-memory stub runner, or eval
fixtures) simply has an empty sampling and no fingerprints.
Report is a Pydantic model. For JSON (dates as ISO strings, the shape a
front end consumes):
report.model_dump_json() # str
report.model_dump(mode="json") # dictEvery string in this document is untrusted. It is either derived from the prose someone submitted or supplied by the caller, and none of it is escaped for your medium. The service does not sanitise it, because it cannot know whether you are rendering to HTML, a terminal, a PDF or a Slack message.
So, wherever a value from this report reaches a user interface:
- Render it as text, never as markup —
textContent, a templating engine with autoescape on, or your platform's equivalent. - Never assign it to
innerHTML,outerHTML,insertAdjacentHTML, or concatenate it into a string you then parse as markup. - Set attributes by property assignment (
node.title = value), not by building an attribute string.
This is a blanket rule rather than a per-field trust table, deliberately: a table has to be maintained on every schema change or it silently starts lying, and a field added later is exactly the one that gets missed.
webapp/report_view.html is a worked example — it builds DOM nodes throughout
and carries no escape helper at all, so there is nothing to forget to call.
An input that cannot be modelled yields no report — the engine returns a
PipelineRejected carrying list[ValidationIssue] instead (see
Integration-Guide). Each issue has a code, a human message, and
optionally the element_id / field it concerns.