Skip to content

05 — Data Modeling

This document describes the Quorum data model (quorum-sec-scan, v0.8.3 — revision 2026-07-04). Quorum is a CLI/Docker consensus security scanning tool: it orchestrates a pool of OSS scanners (trivy, grype, checkov, kics, dockle, kubescape, polaris, kube-score, terrascan, tfsec, regula, conftest), normalizes all output into a canonical in-memory domain model, resolves vulnerability aliases, correlates equivalent findings by a deterministic correlationKey, computes a confidence (consensus) score, and serializes the result to SARIF (primary), JSON or XML.

Because Quorum has no database (see §1), "data model" here means the domain model: a set of Go structs that are immutable throughout the pipeline, with three persistence projections (SARIF/JSON/XML) generated on every run. This document covers the three classic modeling levels — Conceptual, Logical and Physical — adapted to that context, with Mermaid diagrams, invariants and checklists.

Sources verified in code: internal/model/model.go, internal/correlate/key.go, internal/correlate/correlate.go, internal/crosswalk/crosswalk.go, crosswalk/{aws,azure,gcp,k8s}.yaml, internal/consensus/consensus.go, internal/orchestrator/orchestrator.go, internal/report/{sarif,json,xml}.go, internal/severity/severity.go, internal/purl/purl.go, internal/adapter/. Advisory-layer types verified in internal/model/model.go (see §8).


1. No RDBMS — rationale

Quorum does not use a relational database (PostgreSQL, MySQL, SQLite, etc.), nor NoSQL, nor any persistent domain datastore. This is an architectural decision, not a gap.

Criterion Quorum reality
Execution topology Ephemeral CLI process (or Docker container), one run = one scan
Data lifecycle All findings live in memory ([]model.Finding) during a single run and are serialized to file/stdout at the end
State between runs Stateless by design. No history, users, sessions or multi-tenancy
Existing persistence Only file caches: aliases (~/.cache/quorum/aliases.json, perm 0600, with schemaVersion) and the grype DB pre-cached in the :full image (with GRYPE_DB_VALIDATE_AGE=false, no expiry) — rebuildable caches, not a source of truth. The advisory layer adds an optional on-disk cache (--advice-cache, see §8), also rebuildable
Write concurrency None — there are no concurrent writers to coordinate; parallelism is a read fan-out of scanners (goroutines)

Why an RDBMS would be the wrong tool:

  1. No long-lived entities. A database exists to hold state that outlives processes and is queried/updated by multiple clients. Quorum produces a report and exits. The "state" is the SARIF/JSON/XML file.
  2. The natural integration is the CI/security ecosystem. The data's destination is GitHub Code Scanning (via SARIF), pipeline artifacts and exit-code gates — not dashboards querying a database. (Optionally, --metrics emits a Prometheus textfile for telemetry — still an output artifact, not a datastore.)
  3. Reproducibility. The correlationKey/Fingerprint are deterministic and pure (a function of the finding's content). The domain's "primary key" is derived, not a database auto-increment.
  4. Simple, auditable operation. With no database there are no migrations, connection pools, backups or data-at-rest attack surface. This aligns with the project's minimal supply chain principle.

Future proposal (clearly separate, NOT implemented): if a need for history/trends ever arises (e.g. "this CVE has reappeared for 3 releases"), the idiomatic path would be to ingest the already-emitted SARIF/JSON into an external store (e.g. GitHub Advanced Security itself, a data lake, or an append-only time-series SQLite) outside the Quorum binary. The core would remain stateless. This is an idea, not a roadmap commitment.


2. Conceptual Model

At the conceptual level, the Quorum domain describes what is represented, independent of Go or of any file format.

2.1 Domain entities

Entity Conceptual meaning
Target The artifact being scanned: container image, repository/IaC or k8s manifests/cluster
Scanner (Adapter) An OSS engine that inspects the Target and produces native findings
ScannerRun The record of what happened with a scanner in one run (did it run? fail? unavailable?) — transparency: "0 vulns must never look like 'did not run'"
Finding The canonical unit of a finding, normalized from any scanner into a single format
CorrelationKey / Fingerprint The deterministic identity of a finding, used to group equivalent findings from different scanners
MergedFinding The consensus result: a group of equivalent Findings, with aggregated severity and a confidence score
Report The serialized projection (SARIF/JSON/XML) of the set of MergedFindings + ScannerRuns

2.2 Conceptual diagram (ER)

erDiagram
    TARGET ||--o{ SCANNER_RUN : "is scanned by"
    SCANNER_RUN ||--o{ FINDING : "produces (if status=ran)"
    FINDING }o--|| CORRELATION_KEY : "receives identity"
    CORRELATION_KEY ||--|| MERGED_FINDING : "groups into"
    MERGED_FINDING ||--o{ FINDING : "contains (Members)"
    RESULT ||--o{ SCANNER_RUN : "aggregates"
    RESULT ||--o{ MERGED_FINDING : "aggregates"
    RESULT ||--|| REPORT : "serializes as SARIF/JSON/XML"

    TARGET {
        string Type "image|repo|k8s"
        string Ref  "image ref or path"
    }
    SCANNER_RUN {
        string Name
        string Version
        string Status "ran|skipped|unavailable|error|timeout"
        int    Findings
        int    DurationMs
        string Error
    }
    FINDING {
        string Type "VULN|MISCONFIG|SECRET|K8S_POSTURE|IMG_HARDENING"
        string Scanner
        string VulnID
        string PURL
        string CanonicalControl
        string Severity
        string CorrelationKey
        string Fingerprint
    }
    MERGED_FINDING {
        string CorrelationKey PK
        string Severity "aggregated (max)"
        int    DetectionCount
        float  Confidence "0..1"
        string Fingerprint
    }

2.3 Core principle: false split > false merge

The domain's golden rule is that erroneously splitting two distinct findings is preferable to erroneously merging different findings. This permeates the whole identity model (correlationKey), the crosswalk (which only groups rule-ids when equivalence is clear, see §4.5) and consensus: when in doubt, the correlationKey is more specific, and Unmapped findings never merge silently with others (see §4.4).


3. Logical Model

The logical level describes attributes, types, cardinalities, invariants and relationships — still without committing to Go syntax.

3.1 Class diagram

classDiagram
    direction LR

    class Result {
        +Target Target
        +ScannerRun[] Runs
        +Finding[] Findings  "raw, not serialized"
        +MergedFinding[] Merged
        +Time StartedAt
        +Duration Duration
    }

    class Target {
        +TargetType Type
        +string Ref
    }

    class ScannerRun {
        +string Name
        +string Version
        +string Status
        +int Findings
        +Duration Duration
        +string Error
    }

    class Finding {
        +FindingType Type
        +string Scanner
        +string ScannerVersion
        +string VulnID
        +string[] Aliases
        +string PURL
        +string RuleID
        +string CanonicalControl
        +string Category
        +bool Unmapped
        +Resource Resource
        +Location Location
        +Severity Severity
        +float64 CVSS
        +string CorrelationKey
        +string Fingerprint
        +string Title
        +string Description
        +bool Confirmed
        +map Raw "not serialized"
    }

    class MergedFinding {
        +string CorrelationKey
        +FindingType Type
        +string Title
        +Severity Severity
        +string[] DetectedBy
        +int DetectionCount
        +float64 Confidence
        +bool Unmapped
        +Finding[] Members
        +string Fingerprint
        +Remediation Remediation  "advisory, opt-in"
        +DocRef[] References      "advisory, opt-in"
        +Advice Advice            "advisory, opt-in"
    }

    class Resource {
        +string Kind
        +string Name
        +string Namespace
        +string Address
    }

    class Location {
        +string File
        +int StartLine
        +int EndLine
        +string ImageLayer
    }

    class FindingType {
        <<enumeration>>
        VULN
        MISCONFIG
        SECRET
        K8S_POSTURE
        IMG_HARDENING
    }

    class Severity {
        <<enumeration>>
        CRITICAL
        HIGH
        MEDIUM
        LOW
        INFO
        UNKNOWN
        +Rank() int
    }

    Result "1" *-- "1" Target
    Result "1" *-- "0..*" ScannerRun
    Result "1" *-- "0..*" Finding
    Result "1" *-- "0..*" MergedFinding
    Finding "1" *-- "1" Resource
    Finding "1" *-- "1" Location
    Finding "1" --> "1" FindingType
    Finding "1" --> "1" Severity
    MergedFinding "1" o-- "1..*" Finding : Members
    MergedFinding "1" --> "1" FindingType
    MergedFinding "1" --> "1" Severity
    MergedFinding "1" o-- "0..1" Remediation
    MergedFinding "1" o-- "0..*" DocRef : References
    MergedFinding "1" o-- "0..1" Advice

3.2 Enumerations

FindingType — classifies and selects the correlation strategy

The set of FindingType values remains unchanged since v0.2.3 (five values). What changed is the pool of scanners feeding each type: with 12 adapters, more engines produce MISCONFIG and K8S_POSTURE.

Value Meaning Produced by (typical)
VULN Software vulnerability (SCA) trivy, grype
MISCONFIG IaC misconfiguration trivy, checkov, kics, terrascan, tfsec, regula, conftest (policy-as-code)
SECRET Exposed secret trivy (with redaction of the secret's Match)
K8S_POSTURE Kubernetes security posture kubescape, polaris, kube-score
IMG_HARDENING Container image hardening dockle

FindingType is the discriminator that selects the BuildKey algorithm (see §4). Note that conftest is policy-as-code: it evaluates your Rego (from ./policy) and emits MISCONFIG; its identity falls into the MISCONFIG branch of BuildKey like any other IaC.

Severity — single normalized scale

Value Rank() Normalization origin
CRITICAL 5 CVSS ≥ 9.0; labels "CRITICAL"/"CRIT"
HIGH 4 CVSS ≥ 7.0; "HIGH"/"ERROR"/"DANGER"; Dockle "FATAL"
MEDIUM 3 CVSS ≥ 4.0; "MEDIUM"/"MODERATE"/"WARNING"; Dockle "WARN"
LOW 2 CVSS > 0; "LOW"/"MINOR"; Dockle "INFO"
INFO 1 "INFO"/"INFORMATIONAL"/"NEGLIGIBLE"; Dockle "PASS/SKIP/IGNORE"
UNKNOWN 0 CVSS = 0; "UNKNOWN"/"NONE"/empty; unrecognized label

Normalization lives in internal/severity/severity.go: FromCVSS, FromLabel, FromDockle, plus utilities Max, AtLeast (used by --fail-on/--min-severity) and Parse. Rank() makes the scale orderable for aggregation and report sorting.

3.3 Finding attributes (central entity)

Attribute Logical type Required Notes
Type FindingType Yes Correlation discriminator
Scanner string Yes Name of the source adapter
ScannerVersion string No Binary version
VulnID string Conditional (VULN) CVE/GHSA; canonical after alias resolution
Aliases string[] No Other ids reported by the scanner
PURL string Conditional (VULN) pkg:type/ns/name@version
RuleID string Conditional (MISCONFIG/etc.) Native rule id; crosswalk input (scanner\|ruleID)
CanonicalControl string No Crosswalk result (AVD/CWE/CIS) — or already native (trivy/tfsec emit AVD)
Category string No Semantic category (crosswalk fallback)
Unmapped bool No true when the crosswalk did not resolve a control
Resource Resource No Target IaC/k8s object
Location Location No File/line or image layer
Severity Severity Yes Normalized scale
CVSS float64 No 0 = absent
CorrelationKey string Computed Assigned by the correlator, never by the adapter
Fingerprint string Computed sha256(CorrelationKey)
Title string Yes Human-readable title
Description string No
Confirmed bool No Confirmed by an authoritative source (NVD/OSV)
Raw map[string]any No Original payload; not serialized (tag json:"-")

3.4 Relationships and cardinalities

Relationship Cardinality Rule
Result → ScannerRun 1 : 0..N One run per selected adapter (even if skipped/unavailable)
ScannerRun → Finding 1 : 0..N Only status=ran produces findings; the rest produce 0
Finding → CorrelationKey N : 1 Many findings may share the same key
CorrelationKey → MergedFinding 1 : 1 Each distinct key becomes exactly one MergedFinding
MergedFinding → Finding (Members) 1 : 1..N Always ≥ 1 member (a group is never empty)
Finding → Resource / Location 1 : 1 Embedded value objects (always present, possibly empty)
MergedFinding → Remediation / Advice 1 : 0..1 Advisory, opt-in; nil unless --advice enrichment ran (see §8)
MergedFinding → DocRef (References) 1 : 0..N Advisory, opt-in; empty unless --advice enrichment ran

3.5 Invariants and logical constraints

These are the constraints that replace an RDBMS's constraints. There is no engine enforcing them on a table — they are guaranteed by construction in code and/or verified by contract/unit tests (test coverage required in CI).

  • INV-1 (computed identity). Adapters never fill CorrelationKey/Fingerprint. Those fields are assigned centrally in Correlator.Enrich (or in the orchestrator fallback). Origin: comment in model.go and orchestrator.go.
  • INV-2 (Fingerprint derives from the key). Always Fingerprint == sha256hex(CorrelationKey). Pure function, no salt. Origin: correlate.Fingerprint.
  • INV-3 (aggregated severity = maximum). MergedFinding.Severity == max(Members[*].Severity) by Rank(). Origin: consensus.aggregateSeverity.
  • INV-4 (count by distinct scanner). DetectionCount == |distinct(lower(Members[*].Scanner))|, not the number of findings — two findings from the same scanner count as 1. Origin: consensus.distinctScanners/DetectionCount.
  • INV-5 (DetectedBy ordered and unique). List of distinct scanners, lowercased and alphabetically sorted. Origin: distinctScanners (uses sort.Strings).
  • INV-6 (bounded confidence). 0 ≤ Confidence ≤ 1 (via clamp01). Origin: consensus.confidence.
  • INV-7 (Unmapped propagation). MergedFinding.Unmapped == OR(Members[*].Unmapped). Origin: consensus.anyUnmapped.
  • INV-8 (non-merge of unmapped). An Unmapped finding is keyed by UNMAPPED:<lower(scanner)>:<UPPER(ruleID)>, ensuring it never merges silently with another of a different control. Origin: correlate.controlKey.
  • INV-9 (non-empty group). Every MergedFinding has len(Members) ≥ 1; Type, Fingerprint inherit from Members[0]. Origin: consensus.Merge.
  • INV-10 (resolved Title). MergedFinding.Title is the first non-empty title among the members; fallback to the CorrelationKey. Origin: consensus.bestTitle.
  • INV-11 (deterministic report ordering). MergedFindings are ordered by (Severity.Rank desc, Confidence desc, DetectionCount desc, CorrelationKey asc). The final CorrelationKey tie-breaker guarantees total stability. Origin: consensus.Merge (sort.SliceStable).
  • INV-12 (scanner transparency). Every selected adapter yields a ScannerRun with Status ∈ {ran, skipped, unavailable, error, timeout}, even with no findings. Origin: orchestrator.runOne.
  • INV-13 (advisory is presentation-only). Remediation/References/Advice are populated after consensus and never feed CorrelationKey/Fingerprint/Confidence/aggregated severity or the --fail-on gate. Without --advice they stay nil/empty and the output is byte-identical. Origin: field comments in model.go; package internal/advisor/internal/enrich.

Invariant validation checklist (for review/PR)

  • [ ] No adapter assigns CorrelationKey or Fingerprint (INV-1)
  • [ ] Fingerprint matches sha256hex(CorrelationKey) (INV-2)
  • [ ] Merge Severity is the maximum of the members (INV-3)
  • [ ] DetectionCount = distinct scanners, not findings (INV-4)
  • [ ] Confidence stays within [0,1] (INV-6)
  • [ ] Unmapped findings never co-grouped with different controls (INV-8)
  • [ ] Report ordering is fully deterministic (INV-11)
  • [ ] Every selected scanner appears in Runs with a valid status (INV-12)
  • [ ] Advisory fields never alter identity, confidence or the gate; absent without --advice (INV-13)

4. correlationKey and Fingerprint (identity)

The domain's "primary key" is the correlationKey, a pure and deterministic function of the finding's normalized content, defined in internal/correlate/key.go. There is no universal key: each FindingType has its own strategy, reflecting the fact that "equivalence" means different things for a CVE and for a Terraform misconfiguration.

4.1 Identity pipeline

flowchart LR
    A["Normalized Finding<br/>(from adapter)"] --> B{Type?}
    B -->|VULN| C["resolveVuln:<br/>VulnID -> canonical (alias/OSV)"]
    B -->|MISCONFIG / K8S / IMGH| D["resolveControl:<br/>crosswalk -> CanonicalControl<br/>or Unmapped=true"]
    C --> E["BuildKey(f)"]
    D --> E
    E --> F["CorrelationKey"]
    F --> G["Fingerprint = sha256(CorrelationKey)"]
    G --> H["consensus.Merge:<br/>group by CorrelationKey"]

Enrichment and keying happen in Correlator.Enrich; grouping is the responsibility of the consensus package. The correlate package "owns identity", consensus "owns grouping". In resolveControl, findings that already arrive with CanonicalControl filled (trivy and tfsec emit AVD ids natively) skip the crosswalk — auto-correlation with trivy is immediate.

4.2 BuildKey formulas by type

Type correlationKey formula Stability target
VULN VULN\|<UPPER(VulnID)>\|<purl.NameVersion(PURL)> Same CVE + same name@version correlates across scanners, ignoring ecosystem prefix/qualifiers
MISCONFIG MISCONFIG\|<fileKey>\|<resourceType>\|<controlKey> fileKey = lowercased basename; resourceType derived from the address (e.g. aws_s3_bucket)
K8S_POSTURE K8S\|<objectRef>\|<controlKey> objectRef = ns/kind/name. The container is NOT part of the key (see note below)
IMG_HARDENING IMGH\|<controlKey> Hardening is global to the image
SECRET SECRET\|<normPath>\|<lineKey>\|<lower(RuleID)> Normalized path + line + rule
(default) OTHER\|<Scanner>\|<Title> Conservative fallback

Change since v0.2.3 — K8S key without container. The K8S_POSTURE formula no longer includes the Address (container). The comment in key.go explains why: the engines report at different granularities — kubescape at the workload level, polaris per container. Including the container in the key would block consensus across engines. Trade-off consciously accepted: two containers failing the same control in the same workload merge into a single finding — appropriate for posture (false split > false merge).

4.3 Component normalization

Helper What it does Why
purl.NameVersion Strips pkg:, ?/# qualifiers; lowercases Same package correlates despite ecosystem variations
fileKey path.Base + lowercase Scanners report different roots/relativity; only the basename is stable
normPath Normalizes separators, path.Clean, strips ./,/, lowercases Stable path for SECRET
resourceType First segment of the address containing _ (e.g. aws_s3_bucket); fallback to Kind Engines disagree between the Terraform address and the literal resource name
objectRef lower(namespace/kind/name), empty namespacedefault Stable k8s object identity (workload level, no container)
lineKey StartLine (or 0 if absent) Discriminates secrets on the same rule/file

4.4 controlKey and Unmapped handling

controlKey prefers CanonicalControl (uppercased). When no control is resolved, it falls back to UNMAPPED:<lower(scanner)>:<UPPER(RuleID)>. This materializes the false split > false merge principle: a finding with no canonical mapping keeps its own identity and never collides with another of a distinct control.

Documented trade-off (KNOWN ISSUE in code): for MISCONFIG, two distinct resources of the same type with the same control in the same file can over-merge. This was consciously accepted as preferable to never correlating across engines.

4.5 Crosswalk: canonical hubs and versioned format

The CanonicalControl that feeds controlKey comes from the crosswalk (internal/crosswalk/crosswalk.go): a scanner + ruleID → canonical control mapping loaded from the YAML files in ./crosswalk. All mappings were DERIVED from real output of the scanners (run via the :full image against examples/), pairing ids only where the engines flag the same concept on the same resource.

Canonical hubs by domain:

File Hub (canonical id) Coverage Paired engines
crosswalk/aws.yaml AVD (AVD-AWS-####) S3, IAM, EBS, Security Group, RDS, KMS, CloudTrail, VPC flow logs trivy (native), checkov, kics, terrascan, regula
crosswalk/azure.yaml AVD (AVD-AZU-####) Storage account (HTTPS/TLS), Key Vault trivy (native), checkov, kics, terrascan, regula
crosswalk/gcp.yaml AVD (AVD-GCP-####) GCS bucket, firewall (RDP/SSH), Cloud SQL trivy (native), checkov, kics, terrascan, regula
crosswalk/k8s.yaml Kubescape (C-####) privilege-escalation, privileged, non-root, cpu/mem limits, probes, read-only-fs, linux-hardening, automount-SA, network-policy, host-network, host-PID/IPC, capabilities, secrets kubescape (native), polaris, kube-score

Design notes faithful to the code:

  • Trivy and tfsec are the IaC hub. Trivy speaks AVD natively; tfsec also emits AVD ids and therefore auto-correlates with trivy without needing a crosswalk entry (resolveControl returns early when CanonicalControl is already filled).
  • RBAC stays single-engine. In crosswalk/k8s.yaml, kubescape RBAC controls (C-0035/C-0185/C-0272/…) are not paired: they depend on an active cluster context and did not fire over static manifests, so they stay Unmapped rather than forcing a false merge. Documented in the YAML itself.
  • kube-score grouped checks (container-resources = cpu+memory, pod-probes = liveness+readiness) are intentionally left unmapped — mapping a grouped check to a single canonical control would cause over-merge.

Versioned format (schemaVersion)

The loader (crosswalk.Load) accepts two forms of file, trying the versioned one first:

  1. Versioned document (preferred): a map with schemaVersion: 1 + controls: [...]. Used by azure.yaml, gcp.yaml and k8s.yaml. The constant crosswalk.SchemaVersion = 1 defines the current version and allows the format to evolve.
  2. Plain list (legacy, still supported): the YAML is a top-level list of controls (no schemaVersion). Used today by aws.yaml.

Each Control has canonicalControl, category, cwe (optional), title and ids: { scanner: [ruleID, ...] }. The internal index is byRule["<lower(scanner)>|<ruleID>"] → Resolution. A missing crosswalk directory is not an error (Quorum runs without a custom crosswalk; all findings degrade to Unmapped).


5. Consensus and MergedFinding

consensus.Merge (internal/consensus/consensus.go) groups Findings by CorrelationKey and produces scored MergedFindings. Raw detection count is not confidence — engine diversity, severity and authoritative confirmation also weigh in (DESIGN §9).

5.1 Confidence formula

confidence = clamp01(
      0.35 * count
    + 0.25 * diversity
    + 0.25 * severity
    + 0.15 * authoritative
)
Component Calculation Range
count log(1+N) / log(5), N = distinct scanners (diminishing returns) ~0..1
diversity distinct engine families: 1→0.33, 2→0.66, 3+→1.0 0..1
severity CRITICAL=1.0, HIGH=0.8, MEDIUM=0.5, LOW=0.3, INFO/UNKNOWN=0.1 0.1..1
authoritative 1.0 if any member is Confirmed, or is a CVE with CVSS>0; otherwise 0 0 or 1

Engine families (scannerCategory), updated for the 12 scanners: trivy/grypesca; checkov/kics/terrascan/tfsec/regulaiac; conftestpolicy; kubescape/polaris/kube-scorek8s; docklehardening. Unknown scanners become other:<name>, counting as their own family (diversity does not inflate artificially). Diversity rewards consensus across distinct families: two engines of the same family (e.g. checkov + kics, both iac) add less than engines of different families.

5.2 MergedFinding derivation diagram

flowchart TD
    G["Group: Finding[] with same CorrelationKey"] --> S["distinctScanners -> DetectedBy, DetectionCount"]
    G --> A["aggregateSeverity -> Severity (max)"]
    G --> T["bestTitle -> Title"]
    G --> U["anyUnmapped -> Unmapped"]
    S --> C["confidence(members, scanners, agg) -> Confidence"]
    A --> C
    G --> M["MergedFinding"]
    S --> M
    A --> M
    T --> M
    U --> M
    C --> M

Advisory note: when --advice is enabled, an enrichment step runs after Merge and may attach Remediation/References/Advice to each MergedFinding. That step reads the merged finding but never mutates its identity, severity or confidence (INV-13). See §8.


6. Physical Model

At the physical level, Quorum's "storage" is (a) the layout of Go structs in memory and (b) the three serialized projections. There is no SQL schema.

6.1 Physical in memory — Go structs

The canonical definitions are in internal/model/model.go. Relevant physical points:

  • Finding.Raw map[string]any has tag json:"-"never serialized; it exists only for debugging/adapters.
  • Result.Findings has tag json:"-" → the raw findings do not go into the default report; the report exposes Merged (renamed to findings in JSON). Origin: orchestrator.Result.
  • Fields with omitempty disappear when empty (e.g. aliases, purl, cvss, description). The advisory fields remediation, references, advice are all omitempty → absent from output unless --advice populated them.
  • Severity and FindingType are string typedefs — they serialize as literal strings ("CRITICAL", "VULN").

6.2 Physical serialized — three projections

Format Reporter Role Shape
SARIF 2.1.0 sarif.go Primary — GitHub Code Scanning runs[].results[] with partialFingerprints["quorum/v1"]
JSON json.go Generic integration / detail {tool, version, target, scanners, summary, findings} (findings = dump of []MergedFinding)
XML xml.go Legacy/JUnit-like pipelines <quorumReport> mirroring the JSON

Output-path hardening: --output applies filepath.Clean and writes with permission 0600; a target starting with - is refused (protection against argument injection); OSV ids are validated and passed through url.PathEscape. This does not change the data model, but it affects how the projections are written.

6.2.1 Canonical → SARIF mapping

Canonical field SARIF destination
MergedFinding.Fingerprint result.partialFingerprints["quorum/v1"]
Severity result.level (CRITICAL/HIGH→error, MEDIUM→warning, rest→note)
Title result.message.text + rule.shortDescription
Type rule.name + rule.properties.type
VulnID / CanonicalControl / RuleID result.ruleId (in that order of preference; fallback CorrelationKey)
Location.File/StartLine/EndLine result.locations[].physicalLocation (deduplicated per file)
DetectedBy, DetectionCount, Confidence (rounded to 2 places), Severity, CorrelationKey, Unmapped result.properties
Result.Target.Ref + scanner summary run.properties

Version note: report.Version (the fingerprint namespace and SARIF driver version) is pinned to "0.1.0" in code (sarif.go line 13), distinct from the product version (v0.8.3). See Gaps.

6.2.2 JSON structure

{
  "tool": "quorum",
  "version": "0.1.0",
  "target": { "type": "repo", "ref": "./" },
  "scanners": [
    { "name": "trivy", "version": "0.55.0", "status": "ran",
      "findings": 12, "durationMs": 1842 }
  ],
  "summary": {
    "totalFindings": 7,          // = len(Merged)
    "durationMs": 5300,
    "bySeverity": { "CRITICAL": 1, "HIGH": 3 },
    "multiDetected": 2           // findings with detectionCount > 1
  },
  "findings": [ /* []MergedFinding */ ]
}

Each element of findings is a MergedFinding. Under --advice it may carry the optional remediation, references and advice objects (all omitempty); without --advice those keys are absent and the JSON is byte-identical to a plain run.

6.2.3 XML structure

<quorumReport tool="quorum" version="..."> with <target>, <scanners><scanner .../></scanners> and <findings><finding>...</finding></findings>. Each <finding> carries type, severity, detectionCount, confidence, unmapped, fingerprint as attributes, and correlationKey, title, detectedBy>scanner, locations>location as elements.

6.3 Indexes, Triggers, Views, Procedures, Sequences — N/A

Construct Status Rationale
Indexes N/A There is no database to index. The logical "indexes" are built in memory per run and discarded at the end: the map[string][]Finding by CorrelationKey in consensus.Merge and the crosswalk's byRule map[string]Resolution (amortized O(1) access)
Triggers N/A No database and no persistent mutations. The analogous "reaction to an event" is the pure functional pipeline scan → normalize → alias → correlate → score → report
Views N/A The three serializations (SARIF/JSON/XML) are the read "views" of the same canonical set, generated at runtime by the reporters
Stored Procedures / Functions N/A All logic is Go code (e.g. BuildKey, confidence). There is no SQL/PL nor database RPC. The controlled exception is conftest's policy-as-code, which runs the user's Rego (./policy) — but it produces Findings, it does not persist state
Sequences / Auto-increment N/A Identity is derived from content (Fingerprint = sha256(CorrelationKey)), not generated by a sequence
Constraints (FK/UNIQUE/CHECK) N/A in the DBMS; equivalents in code FKs/uniques are guaranteed by construction: grouping by CorrelationKey (logical UNIQUE), Members ⊆ Findings (logical FK). The equivalent CHECKs are the invariants in §3.5
Migrations N/A No persistent schema. Model changes are struct changes + release versioning; external compatibility is the responsibility of the SARIF/JSON/XML contract. The only schema-versioned artifact is the crosswalk (schemaVersion: 1), which evolves by tolerant reading (accepts both legacy and versioned forms), not by migration

Future proposal (NOT implemented): should the external ingest described in §1 be adopted, indexes on (fingerprint) and (vulnId) and materialized trend views would make sense — in the external store, never in the binary.


7. Data dictionary (summary)

Struct Package Role Serialized in
Finding model Normalized canonical unit JSON (inside members), SARIF (source of the fields)
MergedFinding model Scored post-consensus group SARIF/JSON/XML (report unit)
Resource model Target IaC/k8s object embedded
Location model File/line/layer SARIF locations, XML locations
Severity model Normalized scale (enum) string
FindingType model Correlation discriminator (enum) string
Remediation model Deterministic Phase-0 remediation template (advisory) JSON remediation (opt-in, omitempty)
DocRef model External OWASP reference (advisory) JSON references (opt-in, omitempty)
Advice model Opt-in local-LLM recommendation, always labeled (advisory) JSON advice (opt-in, omitempty)
Fix model Verified AI-proposed patch, embedded in Advice (advisory) JSON advice.fix (opt-in, omitempty)
Control / Resolution crosswalk scanner+ruleID → canonical control mapping not serialized (input config, crosswalk/*.yaml)
ScannerRun orchestrator Execution transparency JSON/XML scanners, SARIF run.properties
Result orchestrator Complete scan output base of all reporters
Target adapter Scanned artifact target in all formats

8. Advisory layer in the model (opt-in)

Since v0.8.3, MergedFinding carries an optional advisory layer, enabled by the --advice flag. These fields are presentation-only: they are populated after consensus and never touch CorrelationKey, Fingerprint, Confidence, aggregated severity or the --fail-on gate. Without --advice, they stay nil/empty and the report is byte-identical to a plain run (INV-13). The deterministic core still has no AI; the AI parts are strictly opt-in and off by default.

8.1 New fields on MergedFinding

Field Type Populated by Meaning
Remediation *Remediation Phase 0 (deterministic, no model) Curated remediation template matched by canonicalControl/ruleId/category/type
References []DocRef Phase 0 / Phase 2 (deterministic) OWASP references; Phase 2 retrieves them from a digest-pinned OWASP corpus
Advice *Advice Phase 1 (opt-in local LLM) Natural-language recommendation from an on-host OpenAI-compatible endpoint; always labeled

8.2 Advisory value objects

classDiagram
    class Remediation {
        +string Title
        +string Summary
        +string Snippet
        +string Source  "template — deterministic, never guessed"
    }
    class DocRef {
        +string Title
        +string URL
    }
    class Advice {
        +string Recommendation
        +Fix Fix
        +string Provider  "local | remote"
        +string Model
        +string Label     "AI-generated, advisory only"
    }
    class Fix {
        +string File
        +string Content   "proposed full replacement"
        +bool Verified    "always true when attached"
    }
    Advice "1" o-- "0..1" Fix
  • model.Remediation — a suggested fix. In Phase 0 it is a curated, deterministic template keyed by the finding's canonical control (Source = "template", never guessed). Snippet is illustrative, not an exact diff. Data lives in knowledge/*.yaml (aws/azure/gcp/k8s/image/categories); package internal/enrich.
  • model.DocRef — an external reference (e.g. an OWASP cheat sheet) grounding the remediation in an authoritative, citable source. Phase 2 (RAG-as-artifact, package internal/rag) retrieves these deterministically from the versioned, digest-pinned OWASP corpus (knowledge/owasp/corpus.yaml): lexical retrieval by default (no model), semantic (embeddings) when the corpus is embedded via quorum advise-indexscan auto-picks semantic when the corpus has vectors.
  • model.Advice — the AI-generated advisory layer (Phase 1, opt-in via --advice-provider=local; Phase 3 via =remote). Provider is local or remote, Model is the id the endpoint reported, and Label is always "AI-generated, advisory only". Reproducible via temperature=0 + an on-disk cache keyed by fingerprint+provider+model. Graceful degradation: if the model is unreachable the report ships without AI advice and the scan never fails. Package internal/advisor.
  • model.Fix — an AI-proposed replacement for the offending file, attached only when --fix=suggest is set and it passed the verify-the-fix loop: the patch is applied to a temp copy, re-scanned with the same scanner, and kept only if the finding is gone and the file still parses. Verified is always true when attached (otherwise the fix is dropped). Quorum never auto-applies. Remote provider refuses --fix (it would upload source) and is blocked by --offline; only the normalized finding is ever sent to a remote provider — never source code.

8.3 Serialization

All three fields carry omitempty, so they are absent from JSON/XML unless enrichment populated them. New metrics (only under --advice) reflect the same data: quorum_advice_enriched{kind=remediation|references|recommendation}, quorum_advice_provider{provider}, and quorum_advice_fix{stage=proposed|verified} (the verify-the-fix rate). The knowledge pack + crosswalk get a SLSA build-provenance attestation each release (verify with gh attestation verify knowledge/owasp/corpus.yaml). See 13-ia.md for the honest framing and 21-proposta-ia.md for the full Phase 0–3 design.


Assumptions

  • Code version analyzed: repository state on the main branch at the time of writing (product labeled v0.8.3, revision 2026-07-04). Citations reflect the files read directly (key.go, correlate.go, crosswalk.go, consensus.go, model.go, the crosswalk/*.yaml); no behavior was inferred without reading.
  • Canonical model unchanged at the core. The domain structs and FindingType (5 values) remain identical to v0.2.3; what evolved is the scanner pool (6 → 12), the crosswalk (now with versioned AVD/kubescape hubs), the K8S key (no container), and — new in v0.8.3 — the opt-in advisory fields on MergedFinding (Remediation/References/Advice), which are presentation-only and off by default.
  • report.Version = "0.1.0" was reported faithfully as it is in code. It is assumed to be the version of the output contract/fingerprint namespace, not the product version — see Gaps.
  • DESIGN.md is referenced by sections (§3, §6, §8, §9) as per code comments; the detailed content of that document was not re-read here, only the anchors cited in the source.
  • The JSON/XML examples are illustrative of the shape defined by the struct tags; values are fictitious.
  • "Database" was interpreted as a persistent domain datastore; the file caches (aliases 0600 with schemaVersion, the non-expiring grype DB, the advisory --advice-cache) are treated as rebuildable caches, not as a data model.

Gaps

  • report.Version is hardcoded to "0.1.0" and diverges from the product version (v0.8.3); the code does not make clear whether this is intentional (contract versioning) or lag. Documented as an observation.
  • Mixed crosswalk format. aws.yaml still uses the legacy form (top-level list, no schemaVersion), while azure/gcp/k8s.yaml use the versioned form (schemaVersion: 1 + controls). The loader accepts both; migrating aws.yaml to the versioned form is cosmetic and has not been done.
  • Unlike v0.2.3, polaris and kube-score are no longer placeholders: they are now real adapters (internal/adapter/polaris.go, kubescore.go) and entries in crosswalk/k8s.yaml. The earlier gap ("polaris without an adapter") is resolved.
  • The exact numeric content of some DESIGN.md sections (e.g. the full correlation matrix) was not transcribed; only the behavior present in code was documented.

Open Questions

  • Should the report.Version string come to reflect the product version (via GoReleaser ldflags), or is it deliberately an independent contract versioning?
  • Should crosswalk/aws.yaml be migrated to the versioned format (schemaVersion: 1 + controls) for consistency with the others, or does the legacy form remain supported indefinitely?
  • The kubescape RBAC controls stay Unmapped for lack of a second engine over static manifests. If a scan with an active cluster context makes polaris and kubescape agree, should crosswalk/k8s.yaml gain those pairs?
  • The []MergedFinding dump in the JSON findings field includes Members (each a full Finding); is this a stable, supported output contract or an internal detail subject to change?