| type | reference | ||
|---|---|---|---|
| title | Finding model | ||
| tags |
|
||
| created | 2026-08-20 |
Status: Implemented foundation. The model, its stable identity
computation, and its human/JSON/SARIF-result rendering described below
are implemented and tested library code. Every registered rule
produces real Findings (see Policy rules). controlloop scan and controlloop gate both
consume every registered rule's findings() (see the CLI
reference). Schema 1.1.0 made
affected_agent/capability_path optional together, for a repository-
scoped finding with no single affected agent -- see "affected_agent/
capability_path are optional" below.
controlloop.policy.engine.evaluate (the deterministic rule
engine) reports a RuleObservation per rule: a
rule ID, a message, and evidence -- the minimum shape needed to prove
that engine's own ordering and determinism guarantees. A Finding,
defined in controlloop.findings, is what a RuleObservation becomes
once the additional context a rule invocation carries is attached:
rule identifier and version, severity, confidence, the affected agent,
evidence, a capability path, change state against a baseline,
remediation text, and a documentation URL -- everything a reviewer
needs to judge it without opening the codebase.
class Finding: # controlloop.findings.models
schema_version: str # SemanticVersion, "1.1.0" today
id: str # FindingId: "clp:finding:sha256:<hex>"
rule_id: str # SafeIdentifier
rule_version: str # SemanticVersion; shape only, see below
message: str # SafeText, carried from RuleObservation
severity: FindingSeverity # info | low | medium | high | critical
confidence: Confidence # reused from controlloop.acbom.models
affected_agent: str | None # AgentNodeId, or None (repository-scoped)
evidence: tuple[Evidence, ...] # reused EvidenceCollection, min 1
capability_path: CapabilityPath | None # reused, or None (repository-scoped)
change_state: (
FindingChangeState # added | removed | changed | unknown | not-compared
)
remediation: str # SafeText
documentation_url: str # https://..., shape-validated, never fetchedEvery field is required with no default except affected_agent and
capability_path, both defaulting to None together (schema 1.1.0
-- see "affected_agent/capability_path are optional" below). Omitting a required field, supplying one with an
invalid shape, or supplying exactly one of affected_agent/
capability_path alone, all raise pydantic.ValidationError rather
than producing a partially-populated result. A model_validator
enforces this: both None (repository-scoped, valid), both present
with affected_agent == capability_path.agent_id (agent-scoped, valid
-- the original behavior), or exactly one present (always
rejected).
controlloop.findings.models.finding_from_observation builds a
Finding from one RuleObservation plus the context only a caller
has:
finding_from_observation(
observation, # controlloop.policy.models.RuleObservation
*,
rule_version, severity, confidence, affected_agent,
capability_path, change_state, remediation, documentation_url,
) -> Findingrule_id, message, and evidence are copied from observation
unchanged; id is always computed via finding_id (below) before
construction. This is a convenience constructor, not the only valid
one -- a Finding may also be built directly, supplying an
already-computed id, the same relationship
controlloop.acbom.identity.node_id has to direct Node(...)
construction.
controlloop.policy.models.RuleObservation is unchanged. Its own
docstring already names Finding as its successor: Finding is what
an observation becomes once a rule invocation's own judgment
(severity, confidence, which agent, which capability path, remediation
guidance) is attached -- none of which is inferable from a bare rule
ID, message, and evidence. message is carried onto Finding because
without it, a Finding would name a rule and a severity but never
describe, in words, what was actually found, undermining this
document's own opening claim.
FindingSeverity is a five-member scale (info, low, medium,
high, critical) in ascending declared order. No ranking function,
comparison operator, or configurable gate threshold is defined here.
Which severities fail a build, which rules are mandatory, and how a
repository configures that are all left to Severity, confidence, and
thresholds, which depends on this model rather
than duplicating it. confidence reuses
controlloop.acbom.models.Confidence verbatim -- the same enum every
node, edge, diagnostic, and capability path in this codebase already
uses -- rather than a second confidence vocabulary.
rule_version reuses controlloop.acbom.models.SemanticVersion, the
same annotated type ACBOMDocument.schema_version already uses:
semantic-version shape validation only, unchanged by Rule pack
versioning, which defines the independent
rule-pack registry (RulePack, its own name and version, a content
fingerprint, and --version reporting) without connecting it to this
field or to finding identity -- rule_version here
remains exactly analogous to RuleContext.policy_version in the rule
engine: threaded through and shape-validated, opaque otherwise.
capability_path: reused directly, now optional in lockstep with affected_agent (deliberate, disclosed)
Finding.capability_path reuses controlloop.capability.models. CapabilityPath unmodified. Originally required and unconditional: "this
works specifically because affected_agent is also required: every
finding is inherently agent-scoped, so a capability path... can always
be constructed." A real counter-example was found -- a hardcoded
secret in a shared file has no natural single-agent attribution -- and
it was resolved by making affected_agent optional. capability_path
becomes optional in lockstep as a necessary consequence, not as an
independent design choice: CapabilityPath.agent_id is itself a
required real agent node ID, so a repository-scoped finding structurally
cannot carry one. When affected_agent is present, capability_path
still works exactly as before -- a path from that agent to the resource,
tool, identity, permission, or data-class the finding concerns, even a
single-hop path (CapabilityPath.hops already requires at least one
hop; no multi-hop traversal is mandatory).
A repository-scoped Finding (both fields None) is now valid --
appropriate for a hardcoded secret in a shared file with zero, one, or
many potential agent readers, where "which agent" is not the right
question the way it is for every other rule in this package (undeclared
tools, delegation, broad permissions -- all inherently agent-scoped).
This is an additive, backward-compatible change: schema_version moves
from "1.0.0" to "1.1.0" (MINOR, per Schema
versioning's general vocabulary -- a new optional
field, nothing removed, renamed, retyped, or newly rejected). Every one
of the other nine original rules still always supplies both fields,
unaffected by this change; controlloop.policy.rules.hardcoded_secrets
was, when the change was made, the one rule that constructs a
repository-scoped Finding.
Stable identity (below) needed a real answer for this new case too:
finding_id gains one additive parameter, evidence_paths, consulted
only when affected_agent is None, populated automatically by
finding_from_observation from the observation's own evidence. The
agent-scoped identity payload is completely unchanged by this addition.
FindingChangeState mirrors controlloop.diff.models.ChangeKind's
four members one-for-one (added, removed, changed, unknown)
without importing or modifying that type, and adds a fifth,
not-compared. RuleContext.diff is DiffResult | None -- a
rule may run with no baseline at all, and every finding still needs a
change_state. Collapsing "no baseline was compared" into
ChangeKind.UNKNOWN would contradict that member's own documented
meaning (a specific claim about a completed comparison whose
confidence dropped, not an absent one) and would violate this
repository's "unknown, never omitted" invariant -- the same reasoning
controlloop.diff.report.ComparisonScope already applied, in the same
non-invasive way, to keep "not evaluated" distinct from "confirmed
unchanged."
A new DocumentationUrl type requires an https:// prefix, no control
characters, and a bounded length. ControlLoop never fetches the URL:
verifying that a finding's documentation URL actually resolves is a
separate concern.
controlloop.findings.identity.finding_id reuses
controlloop.acbom.identity's exact canonicalization scheme: a fixed
identity version, a fixed namespace ("controlloop.finding"), a
canonical JSON payload sorted and hashed with SHA-256, rendered as
clp:finding:sha256:<hex>.
Identity material is deliberately narrow:
rule_id,affected_agentcapability_path.agent_id,.target_id,.target_type, and the ordered tuple of every hop'sedge_id
Deliberately excluded, each for a disclosed reason: evidence
(mutable provenance, the same exclusion node/edge identity already
applies); severity/confidence (a policy or analysis change must not
mint a "new" finding for the same issue); change_state (differs by
definition across different baseline comparisons -- including it would
make "the same issue is recognizable between scans" impossible for the
most common case); message/remediation/documentation_url/
rule_version (presentation and version text, not semantic identity).
rule_version's exclusion is a deliberate judgment call: a rule's
version increment describing a still-detected issue does not, in this
design, create a new issue. Rule pack
versioning has not yet defined how a rule's
version relates to identity and may revisit this.
Repository-scoped identity. When affected_agent is None, none of the agent-scoped identity material above is
available -- there is no agent and no capability path. finding_id
falls back to evidence_paths, the finding's own sanitized
repository-relative evidence paths (sorted, deduplicated), the closest
allowlisted, non-mutable anchor left -- the same category of identity
material (source_path) controlloop.acbom.identity.SemanticIdentity
already treats as approved for a node, reused here for the one case
that has nothing else. Line numbers still never participate, so a
line added elsewhere in the file never mints a new identity. A rule
that can report several findings in one file also supplies an
occurrence, a short nonsecret label that tells them apart:
policy.hardcoded-secrets uses the detected credential kind and its
ordinal among detections of that kind in the file, for example
aws-access-key-id#1 or stripe-secret-key#1. Every detection
therefore has its own identity, and a suppression of one never silences
another. The ordinal changes only when a detection of the same kind is
added or removed above it in the same file; the suppression then
reports stale instead of silently moving to a different secret. The
occurrence never contains any part of the matched value. The
agent-scoped payload shape is completely unchanged by this addition,
and a rule that supplies no occurrence keeps its original IDs.
- JSON -- ordinary
Finding.model_dump_json(). No dedicated function; this is the same deterministic Pydantic serialization every other computed-report model in this codebase already uses. - Human --
controlloop.findings.rendering.render_finding_textfollowscontrolloop.diagnostics.render_text's glyph-and-label convention: one summary line, then one location line per evidence entry carrying apath, rendered throughcontrolloop.redaction.source_reference(never raw content). - SARIF --
controlloop.findings.rendering.finding_to_sarif_resultreturns one SARIFresultobject shape only -- not a complete SARIF log document.runs[].tool.driver.rules[],originalUriBaseIds, and the rest of that document-level structure are built separately -- see SARIF export format.severitymaps to SARIF'slevel:info/low->note,medium->warning,high/critical->error. Every model field without a native SARIFresultslot (finding ID, rule version, confidence, affected agent, capability path, change state, remediation, documentation URL) is carried in aproperties.controlloopbag, SARIF's own documented open extension point.
"Serialize identically" is read as: the same underlying data is present
and mutually consistent across all three renderings -- not
byte-identical text across three structurally different formats. Every
evidence entry with a path renders as one location in all three
formats; an entry with only a document_digest has no location to
render in any of them and is simply omitted, not raised as an error.
No new raw-content path exists: message/remediation are SafeText
(the same control-character-excluding, length-bounded type used
throughout this codebase), and evidence reuses
controlloop.acbom.models.EvidenceCollection/Evidence verbatim --
structurally incapable of carrying file content. controlloop.findings
is proven, by the same ast-based structural check
test_diff_module_never_reads_raw_content established, to import none
of pathlib, subprocess, socket, os, shutil, urllib, http
and to contain no bare open(...) call. A canary test plants a
secret-shaped value inside a Finding's message and remediation,
renders it as human text, JSON, and a SARIF result inside
controlloop.redaction.redirected_streams, and asserts the planted
value never survives any of the three -- proving the CLI's single
enforced redaction boundary still catches a hypothetical careless rule
author.
- Every registered rule produces real
Findings (see Concrete policy rules). Expiring suppressions are a suppression mechanism, not a rule. FindingSeverityitself is still a field shape only, defined entirely in this module. The ranking/threshold/confidence-downgrade decision core now exists as a separate, later layer -- see Severity, confidence, and thresholds -- andscan/gateboth consume it viaevaluate_gate; the rules above still each choose their own fixed severity value per finding, which the threshold then classifies as blocking or warning.- A rule-pack versioning scheme now exists as a separate registry -- see
Rule pack versioning -- but
rule_versionitself is still shape-validated and otherwise opaque, and is deliberately excluded from finding identity; the two are not connected.CURRENT_RULE_PACKcarries every registered rule with a decidedRuleMetadata.mandatoryflag per rule. controlloop scanandcontrolloop gateboth consume this viaALL_RULES(see CLI reference).- A repository-scoped finding's
occurrenceordinal follows file order, so adding a secret of the same kind above an existing one in the same file gives the existing one a new identity -- see "Finding identity" above. finding_to_sarif_resultreturns one SARIFresultobject, not a complete SARIF log document.- A finding's documentation URL is validated for shape only and is never fetched or verified to resolve.
- Review Deterministic rule engine for
RuleObservation, the typefinding_from_observationconsumes. - Review Material capability taxonomy
and Effective authority model for
CapabilityPath, reused unmodified asFinding.capability_path. - Review Diff output format for
ChangeKind, the typeFindingChangeStatemirrors. - Review Severity, confidence, and thresholds for
the ranking/threshold decision core built on
FindingSeverity. - Review the CLI reference for what is available today.