Witness Schema v1 Contract
Version:
stellaops.witness.v1
Status: Active (implemented)
Sprint:SPRINT_3700_0001_0001_witness_foundation
Overview
Audience: engineers producing, consuming, or verifying Stella Ops reachability witnesses (Scanner, Attestor, CLI, and Evidence Locker integrators).
A witness is a cryptographically-signed proof of a reachability path from an entrypoint to a vulnerable sink. Witnesses provide:
- Auditability - Proof that a path was found at scan time
- Offline verification - Verify claims without re-running analysis
- Provenance - Links to the source graph and analysis context
- Transparency - Can be published to transparency logs
Two representations
This contract describes two related-but-distinct on-disk shapes. Do not confuse them:
- Wire / DSSE payload schema (
stellaops.witness.v1) — the canonical signed payload produced and consumed by the Scanner. This is the authoritative runtime shape. Modelled byStellaOps.Scanner.Reachability.Witnesses.PathWitness(snake_case JSON). This is the signed payload inside a persisted witness record, not the record or verification response wrapper. - In-toto predicate JSON Schema (
stellaops-path-witness.v1.schema.json) — a separate draft JSON Schema used by the Attestor for predicate validation. It uses a UUIDwitness_id, a separatewitness_hash, aprovenanceblock, a nestedpath.{entrypoint,sink,steps,hop_count}structure, andcas://-styleevidence_uris. These fields are NOT present in the wire schema.
Scanner’s record API returns WitnessResponseDto: a camelCase wrapper containing the persisted record GUID witnessId, witnessHash, scan/run and signature metadata, the nested payload, and nullable dsseEnvelope. Lists return { witnesses, totalCount }; verification returns the separate WitnessVerificationResponseDto with status, error and verification metadata. The nested payload’s witness_id is not the persisted record GUID. CLI JSON for Scanner records retains these wrappers; list output additionally marks the public reachability verdict unknown because the record does not supply one. These response contracts are defined in src/Scanner/StellaOps.Scanner.WebService/Endpoints/WitnessEndpoints.cs and shown in the scoped owner reference.
The same field-shape divergence is reflected in storage: the scanner.witnesses table (see Storage) persists predicate-style columns (witness_hash, witness_type, graph_hash, scan_id, run_id, entrypoint_fqn, sink_cve, created_at) in addition to the full wire payload in payload_json.
Sections below are labelled with which representation they describe.
Schema Definition — wire / DSSE payload (stellaops.witness.v1)
Source of truth:
src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/PathWitness.csandWitnessSchema.cs. This is the shape that is DSSE-signed, stored inscanner.witnesses.payload_json, and exposed as the nestedpayloadin Scanner record API and CLI responses. This payload uses snake_case JSON; the outer record wrapper uses camelCase.
{
"witness_schema": "stellaops.witness.v1",
"witness_id": "wit:sha256:abcd1234...",
"artifact": {
"sbom_digest": "sha256:...",
"component_purl": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1"
},
"vuln": {
"id": "CVE-2021-44228",
"source": "NVD",
"affected_range": "<2.15.0"
},
"entrypoint": {
"kind": "http",
"name": "GET /api/users/{id}",
"symbol_id": "com.example.MyController.handleRequest"
},
"path": [
{
"symbol": "MyController.handleRequest",
"symbol_id": "com.example.MyController.handleRequest",
"file": "src/main/java/com/example/MyController.java",
"line": 45,
"column": null
},
{
"symbol": "LoggingService.logMessage",
"symbol_id": "com.example.LoggingService.logMessage",
"file": "src/main/java/com/example/LoggingService.java",
"line": 23
},
{
"symbol": "Logger.log",
"symbol_id": "org.apache.log4j.Logger.log",
"file": null,
"line": null
}
],
"sink": {
"symbol": "Logger.log",
"symbol_id": "org.apache.log4j.Logger.log",
"sink_type": "deserialization"
},
"gates": [
{
"type": "AuthRequired",
"guard_symbol": "com.example.MyController.requireAuth",
"confidence": 0.92,
"detail": "Requires authenticated user"
}
],
"evidence": {
"callgraph_digest": "blake3:...",
"surface_digest": "sha256:...",
"analysis_config_digest": "sha256:...",
"build_id": "gnu-build-id:..."
},
"observed_at": "2025-12-18T12:00:00Z",
"path_hash": "path:sha256:...",
"node_hashes": ["sha256:...", "sha256:..."],
"evidence_uris": ["evidence:callgraph:blake3:...", "evidence:sbom:sha256:..."],
"predicate_type": "https://stella.ops/predicates/path-witness/v1",
"observation_type": 0,
"claim_id": null,
"observations": null,
"symbolization": null
}
Field Definitions — wire schema
Root Fields
| Field | Type | Required | Description |
|---|---|---|---|
witness_schema | string | Yes | Must be stellaops.witness.v1 |
witness_id | string | Yes | Content-addressed ID: wit:sha256:<hex> (see Witness ID) |
artifact | object | Yes | SBOM/component context |
vuln | object | Yes | Vulnerability the witness concerns |
entrypoint | object | Yes | Entry point that starts the path |
path | array | Yes | Flat ordered list of PathStep (caller -> callee) |
sink | object | Yes | Vulnerable sink at end of path |
gates | array | No | Detected guards/controls along the path |
evidence | object | Yes | Evidence digests for reproducibility |
observed_at | ISO8601 | Yes | When the witness was generated (UTC) |
path_hash | string | No | Path fingerprint (path:sha256:<hex>; see Path Hash Recipe) |
node_hashes | array | No | Top-K node hashes (sha256:<hex>), deterministically ordered |
evidence_uris | array | No | evidence:<kind>:<digest> traceability URIs |
predicate_type | string | No | Default https://stella.ops/predicates/path-witness/v1 |
observation_type | enum (int) | No | 0=Static (default), 1=Runtime, 2=Confirmed (serialized as integer) |
claim_id | string | No | Links runtime witnesses to a static claim: claim:<artifact-digest>:<path-hash> |
observations | array | No | Runtime observations (empty for purely static witnesses) |
symbolization | object | No | Deterministic symbolization tuple; required for runtime/confirmed witnesses (see Runtime Symbolization Tuple) |
There is no
witness_hash,witness_type,provenance,created_at, orhop_countfield in the wire schema. Those fields belong to the in-toto predicate / storage row only (see below).
artifact
| Field | Type | Required | Description |
|---|---|---|---|
sbom_digest | string | Yes | SHA-256 digest of the SBOM |
component_purl | string | Yes | PURL of the vulnerable component |
vuln
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Vulnerability identifier (e.g., CVE-2024-12345) |
source | string | Yes | Source (e.g., NVD, OSV, GHSA) |
affected_range | string | Yes | Affected version range expression |
entrypoint
| Field | Type | Required | Description |
|---|---|---|---|
kind | string | Yes | Entrypoint kind (e.g., http, grpc, cli, job, event) |
name | string | Yes | Human-readable name (e.g., GET /api/users/{id}) |
symbol_id | string | Yes | Canonical symbol ID for the entrypoint |
Path Step (path[])
| Field | Type | Required | Description |
|---|---|---|---|
symbol | string | Yes | Human-readable symbol name |
symbol_id | string | Yes | Canonical symbol ID |
file | string | No | Source file path (null for external/binary symbols) |
line | integer | No | Line number (1-based) |
column | integer | No | Column number (1-based) |
The wire
PathStephas noindex,call_site, oredge_typefield — those exist only in the in-toto predicate’spathStepdefinition.
sink
| Field | Type | Required | Description |
|---|---|---|---|
symbol | string | Yes | Human-readable symbol name |
symbol_id | string | Yes | Canonical symbol ID |
sink_type | string | Yes | Sink taxonomy (e.g., deserialization, sql_injection, path_traversal) |
Gates (gates[])
Optional array of detected guards/mitigating controls along the path.
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes | Gate type. Emitted as the GateType enum’s .ToString() value (AuthRequired, FeatureFlag, AdminOnly, NonDefaultConfig), not a snake_case enum |
guard_symbol | string | Yes | Symbol that implements the gate |
confidence | number | Yes | Confidence level (0.0 - 1.0) |
detail | string | No | Human-readable detail about the gate |
evidence
| Field | Type | Required | Description |
|---|---|---|---|
callgraph_digest | string | Yes | BLAKE3 digest of the call graph used |
surface_digest | string | No | SHA-256 digest of the attack surface manifest |
analysis_config_digest | string | No | SHA-256 digest of the analysis configuration |
build_id | string | No | Build identifier for the analyzed artifact |
In-toto predicate JSON Schema (separate artifact)
Source of truth:
src/Attestor/StellaOps.Attestor.Types/schemas/stellaops-path-witness.v1.schema.json($idhttps://stella.ops/schemas/predicates/path-witness/v1).
This draft-2020-12 JSON Schema is used by the Attestor for predicate validation and has a different shape than the wire payload. It is the origin of the historical example previously shown in this document. Its required fields are witness_id, witness_hash, provenance, path.
{
"witness_id": "uuid",
"witness_hash": "sha256:abcd1234... (pattern ^(blake3|sha256):[a-f0-9]{64}$)",
"witness_type": "reachability_path (enum: reachability_path | gate_proof; default reachability_path)",
"provenance": {
"graph_hash": "sha256:efgh5678... (required)",
"scan_id": "uuid",
"run_id": "uuid",
"analyzer_version": "1.0.0 (required)",
"analysis_timestamp": "2025-12-18T11:59:00Z (required)"
},
"path": {
"entrypoint": { "fqn": "...", "kind": "http_handler", "location": {"file": "...", "line": 42}, "node_hash": "sha256:..." },
"sink": { "fqn": "...", "cve": "CVE-2021-44228", "package": "pkg:maven/...", "node_hash": "sha256:..." },
"steps": [
{ "index": 0, "fqn": "...", "call_site": "...:45", "edge_type": "call", "node_hash": "sha256:..." }
],
"hop_count": 3,
"path_hash": "sha256:...",
"node_hashes": ["sha256:..."]
},
"gates": [
{ "type": "auth_required", "location": "MyController.java:40", "description": "Requires authenticated user" }
],
"evidence": {
"graph_fragment_hash": "sha256:ijkl9012...",
"path_hash": "sha256:mnop3456..."
},
"evidence_uris": {
"graph": "cas://sha256:...",
"sbom": "cas://sha256:...",
"attestation": "cas://sha256:...",
"rekor": "https://rekor.sigstore.dev/..."
}
}
Predicate enum domains (from the JSON Schema):
| Field | Allowed values |
|---|---|
witness_type | reachability_path, gate_proof |
path.entrypoint.kind | http_handler, grpc_handler, cli_main, scheduler, message_handler, other |
path.steps[].edge_type | call, virtual, static, sink, interface, delegate |
gates[].type | auth_required, feature_flag, admin_only, non_default_config, rate_limited, other |
Hash patterns in the predicate schema accept either
blake3:orsha256:prefixes (^(blake3\|sha256):[a-f0-9]{64}$), exceptpath.path_hash,node_hashes, and*.node_hashwhich are^sha256:[a-f0-9]{64}$only.
Witness ID (content addressing)
The wire witness_id is a content address, not a UUID. It is computed by PathWitnessBuilder.ComputeWitnessId over a fixed subset of fields:
- Build a canonical projection containing exactly:
witness_schema,artifact,vuln,entrypoint,path,sink,evidence(snake_case, no indentation, Web defaults). Note this projection excludesgates,observed_at,path_hash,node_hashes,evidence_uris,predicate_type, and the runtime fields. - Hash the UTF-8 bytes via
ICryptoHash.ComputePrefixedHashForPurpose(json, HashPurpose.Content). The default compliance profile mapsHashPurpose.Contentto SHA-256 (prefixsha256:); the FIPS profile is also SHA-256. - Prefix the result with
wit:(WitnessSchema.WitnessIdPrefix).
Result format: wit:sha256:<64-hex>.
var canonical = new {
witness.WitnessSchema, witness.Artifact, witness.Vuln,
witness.Entrypoint, witness.Path, witness.Sink, witness.Evidence
};
var json = JsonSerializer.SerializeToUtf8Bytes(canonical, jsonOptions);
var hash = _cryptoHash.ComputePrefixedHashForPurpose(json, HashPurpose.Content); // "sha256:<hex>"
var witnessId = $"{WitnessSchema.WitnessIdPrefix}{hash}"; // "wit:sha256:<hex>"
The separate
witness_hashvalue (predicate / storage column) follows the predicate schema pattern^(blake3|sha256):[a-f0-9]{64}$and is a hash of the canonical witness payload. It is distinct fromwitness_id. There is no BLAKE3-over-the-whole-witness-excluding-created_at recipe in the implementation. The Scanner producer (see Producer) computes it over the complete canonical payload bytes, the DSSE payloadWitnessDsseSignersigns, with the content hash of the active crypto profile (sha256:by default). Because that payload includesobserved_at, two runs that build the same witness (samewitness_id) at different times store differentwitness_hashvalues.
DSSE Constants
Sprint: SPRINT_3700_0001_0001 (WIT-007C)
The following constants are used for DSSE envelope creation and verification:
| Constant | Value | Location |
|---|---|---|
| Payload Type | application/vnd.stellaops.witness.v1+json | WitnessSchema.DssePayloadType |
| Schema Version | stellaops.witness.v1 | WitnessSchema.Version |
| Witness ID prefix | wit: | WitnessSchema.WitnessIdPrefix |
| JSON Schema URI | https://stellaops.org/schemas/witness-v1.json | WitnessSchema.JsonSchemaUri |
| Predicate type | https://stella.ops/predicates/path-witness/v1 | WitnessPredicateTypes.PathWitnessCanonical, WitnessSchema.PredicateType |
Note: the legacy short form
stella.ops/pathWitness@v1and the URL varianthttps://stella.ops/pathWitness/v1were deleted 2026-09-02 (P8-14, S2.11) -WitnessSchema.PredicateTypenow equals the canonical URI, matching whatPathWitnessBuilderhas always emitted. The DSSEpayloadTypewritten byWitnessDsseSignerisWitnessSchema.DssePayloadType(application/vnd.stellaops.witness.v1+json), not an in-toto predicate type.WitnessSchema.JsonSchemaUri(https://stellaops.org/schemas/witness-v1.json) is the wire-schema URI and is distinct from the in-toto predicate schema$idhttps://stella.ops/schemas/predicates/path-witness/v1.
Witness Types
Defined as WitnessSchema.WitnessTypeReachabilityPath / WitnessTypeGateProof. These values populate the storage witness_type column and the in-toto predicate witness_type enum. The wire PathWitness record itself has no witness_type field — it always represents a reachability path.
| Value | Description |
|---|---|
reachability_path | Path witness from entrypoint to vulnerable sink |
gate_proof | Evidence of mitigating control (gate) along path |
Canonical Predicate Type and Aliases
Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash
Sprint: SPRINT_20260112_008_DOCS_path_witness_contracts (PW-DOC-001)
The canonical predicate type for path witnesses is:
https://stella.ops/predicates/path-witness/v1
The alias forms (stella.ops/pathWitness@v1 legacy short form, https://stella.ops/pathWitness/v1 URL variant) were deleted 2026-09-02 (SPRINT_20260824_010 P8-14, S2.11): producers always emitted the canonical form, so the alias acceptance had no producer. Consumers accept the canonical type only.
Node Hash Recipe
Canonical node hash recipe for deterministic static/runtime evidence joining.
Recipe
NodeHash = SHA256(normalize(PURL) + ":" + normalize(SYMBOL_FQN))
Output format: sha256:<64-hex-chars>
PURL Normalization Rules
- Lowercase scheme (
pkg:) - Lowercase type (e.g.,
NPM->npm) - Preserve namespace/name case (some ecosystems are case-sensitive)
- Sort qualifiers alphabetically by key
- Remove trailing slashes
- Normalize empty version to
unversioned
Symbol FQN Normalization Rules
- Trim whitespace
- Normalize multiple dots (
..) to single dot - Normalize signature whitespace:
(type,type)->(type, type) - Empty signatures become
() - Collapse the module-level placeholder
._.to.
Caveats (matching the current
NodeHashRecipecode, not just its XML comments): rules 5/6 above are partial.NormalizePurllowercases the scheme and type and sorts qualifiers, but does not substitute an empty version withunversioned(despite the doc-comment).NormalizeSymbolFqncollapses the literal._.placeholder rather than stripping bare_type placeholders.
Example
Input:
PURL: pkg:npm/lodash@4.17.21
Symbol: lodash.merge(object, object)
Normalized Input:
"pkg:npm/lodash@4.17.21:lodash.merge(object, object)"
Output:
sha256:a1b2c3d4e5f6... (64 hex chars)
Implementation
See src/__Libraries/StellaOps.Reachability.Core/NodeHashRecipe.cs (NodeHashRecipe.ComputeHash / NormalizePurl / NormalizeSymbolFqn). The normalization rules above describe this canonical recipe.
Divergence warning:
PathWitnessBuilderdoes not callNodeHashRecipe. Its privateComputeNodeHashuses a simplified normalization — it lowercases the entire PURL (purl.Trim().ToLowerInvariant()) and does not sort qualifiers or expand empty versions — then hashesSHA256(normalizedPurl + ":" + symbolFqn.Trim()). The two recipes can therefore produce different node hashes for the same input. The canonicalNodeHashRecipeis the intended long-term recipe; the builder’s inline variant is the value actually emitted inPathWitness.node_hashestoday.
Path Hash Recipe
Canonical path hash recipe for deterministic path fingerprinting.
Recipe
PathHash = SHA256(nodeHash1 + ">" + nodeHash2 + ">" + ... + nodeHashN)
The > separator represents directed edges in the path.
Top-K Selection
For efficiency, witnesses include a top-K subset of node hashes:
- Take first K/2 nodes (entry points)
- Take last K/2 nodes (exit/vulnerable points)
- Deduplicate while preserving order
- Default K = 10
PathFingerprint Fields
The PathFingerprint record (PathHashRecipe.CreateFingerprint) carries:
| Field (C# property) | Type | Description |
|---|---|---|
PathHash | string | sha256:<hex> of full path |
NodeCount | integer | Total nodes in path |
TopKNodeHashes | array | Top-K node hashes for lookup |
SourceNodeHash | string | Hash of entry (first) node |
SinkNodeHash | string | Hash of sink (last) node |
PathFingerprintis an in-memory record only; it is not embedded as apath_fingerprintobject in the witness JSON. The witness instead carries the flatpath_hashandnode_hashesfields.
Implementation
See src/__Libraries/StellaOps.Reachability.Core/PathHashRecipe.cs (PathHashRecipe.ComputeHash / ComputeWithTopK / CreateFingerprint).
Divergence warning: the
>-separated recipe above is the canonicalPathHashRecipe, but thepath_hashactually emitted on aPathWitnessis produced byPathWitnessBuilder’s privateComputeCombinedPathHash, which uses a different recipe:"path:sha256:" + SHA256(hexPart1 + ":" + hexPart2 + ... )— i.e. apath:sha256:prefix and a:separator over the hex parts of each node hash (not the>directed-edge separator, and not the baresha256:prefix). The builder also computes node hashes over all path steps for the path hash, while exposing only the deduplicated top-K (sorted, K=10) innode_hashes.
Evidence URI Fields
There are two distinct evidence-URI conventions; do not mix them.
Wire schema — evidence_uris (flat string array)
In the wire payload, evidence_uris is an array of strings built by PathWitnessBuilder.BuildEvidenceUris. Each entry uses the evidence:<kind>:<value> scheme and is emitted only when the corresponding request field is present:
| Entry | Emitted when | Example |
|---|---|---|
evidence:callgraph:<callgraphDigest> | CallgraphDigest set | evidence:callgraph:blake3:... |
evidence:sbom:<sbomDigest> | SbomDigest set | evidence:sbom:sha256:... |
evidence:surface:<surfaceDigest> | SurfaceDigest set | evidence:surface:sha256:... |
evidence:build:<buildId> | BuildId set | evidence:build:gnu-build-id:... |
{
"evidence_uris": [
"evidence:callgraph:blake3:abc123...",
"evidence:sbom:sha256:def456..."
]
}
In-toto predicate schema — evidence_uris (object with cas://)
The predicate JSON Schema (stellaops-path-witness.v1.schema.json) instead defines evidence_uris as an object whose graph/sbom/attestation values match ^cas://sha256:[a-f0-9]{64}$ and whose rekor value is a free-form URI:
{
"evidence_uris": {
"graph": "cas://sha256:abc123...",
"sbom": "cas://sha256:def456...",
"attestation": "cas://sha256:ghi789...",
"rekor": "https://rekor.sigstore.dev/api/v1/log/entries/abc123def456"
}
}
There are no
graph_uri/sbom_uri/attestation_uri/rekor_uriscalar fields anywhere in the implementation.
stella:// evidence URIs (separate builder)
StellaOps.Reachability.Core.EvidenceUriBuilder produces a third, unrelated URI scheme used for ReachGraph/runtime-fact references — stella://reachgraph/<digest>, stella://signals/runtime/<tenant>/<digest>, stella://cvemap/<cve>, stella://attestation/<digest>, stella://hybrid/<digest> — not cas:// and not the witness evidence: scheme.
Runtime Symbolization Tuple
Runtime witnesses — those whose observation_type is Runtime (1) or Confirmed (2), i.e. anything other than Static (0), or that carry a non-empty observations array — must include a deterministic symbolization tuple. This is enforced by PathWitness.ValidateDeterministicSymbolization, which WitnessDsseSigner calls before signing and during verification (a runtime witness with a missing or invalid symbolization tuple fails both signing and verification):
{
"symbolization": {
"build_id": "gnu-build-id:...",
"debug_artifact_uri": "cas://symbols/by-build-id/.../artifact.debug",
"symbol_table_uri": "cas://symbols/by-build-id/.../symtab.json",
"symbolizer": {
"name": "llvm-symbolizer",
"version": "18.1.7",
"digest": "sha256:..."
},
"libc_variant": "glibc",
"sysroot_digest": "sha256:..."
}
}
Validation rules:
build_id,symbolizer.name,symbolizer.version,symbolizer.digest,libc_variant, andsysroot_digestare required.- At least one of
debug_artifact_uriorsymbol_table_urimust be present. - Missing runtime symbolization inputs must fail witness signing/verification validation.
- Runtime observation arrays must be canonicalized before witness hashing/signing (stable sort by timestamp and deterministic tiebreakers) so equivalent inputs produce byte-identical DSSE payloads.
Runtime Witness Artifact Triplet (MWD-004)
Source of truth:
src/EvidenceLocker/__Libraries/StellaOps.EvidenceLocker.Export/Models/BundleManifest.cs(ArtifactEntry,RuntimeWitnessIndexKey,RuntimeWitnessArtifactRoles,BundlePaths,BundleMediaTypes).
Runtime witnesses exported through Evidence Locker use a deterministic three-file profile, stored under the bundle’s runtime-witnesses/ directory (BundlePaths.RuntimeWitnessesDirectory) and indexed via the manifest’s runtimeWitnesses array:
trace.json- canonical witness payload (media typeapplication/vnd.stellaops.witness.v1+json)trace.dsse.json- DSSE envelope overtrace.json(media typeapplication/vnd.dsse.envelope+json)trace.sigstore.json- Sigstore bundle for offline replay (media typeapplication/vnd.dev.sigstore.bundle.v0.3+json)
Each manifest ArtifactEntry links to:
witnessIdwitnessRole— one oftrace,dsse,sigstore_bundle(RuntimeWitnessArtifactRoles)witnessIndex— the deterministic replay lookup keys (RuntimeWitnessIndexKey):buildIdkernelReleaseprobeIdpolicyRunId
linkedArtifacts— paths of the sibling triplet files
(Field names above are the JSON property names; C# uses PascalCase, e.g. WitnessRole.)
Offline verification must use only bundle-contained artifacts; no network lookups are required for triplet integrity checks.
Cross-Distro Replay Matrix Verification (MWD-005)
Deterministic replay verification must include a minimum matrix of:
- Three kernel releases.
- Both
glibcandmusllibc variants. - Fixed witness artifacts replayed across matrix rows with byte-identical replay-frame output.
QA evidence for MWD-005 was intended to be captured at:
docs/qa/feature-checks/runs/signals/ebpf-micro-witness-determinism/run-001/tier2-replay-matrix-tests.logdocs/qa/feature-checks/runs/signals/ebpf-micro-witness-determinism/run-001/tier2-replay-matrix-summary.json
Status — evidence pending. Neither file exists in the repository yet, and the
ebpf-micro-witness-determinism/run-001/directory is absent. The feature is documented atdocs/modules/signals/contracts/ebpf-micro-witness-determinism-profile.mdanddocs/product/ebpf-micro-witness-determinism.md, but the cited replay-matrix run artifacts have not yet been produced. Treat the matrix requirement as a roadmap target until the evidence lands.
DSSE Signing
Witnesses are signed by WitnessDsseSigner.SignWitness using DSSE (Dead Simple Signing Envelope). The payload is the witness serialized via CanonJson.Canonicalize with snake_case naming, no indentation, and null-omission (DefaultIgnoreCondition.WhenWritingNull). The signature is produced over the DSSE PAE by the shared EnvelopeSignatureService and stored base64 (standard, via Convert.ToBase64String):
{
"payloadType": "application/vnd.stellaops.witness.v1+json",
"payload": "<base64-encoded canonical witness JSON>",
"signatures": [
{
"keyid": "<signing key id>",
"sig": "<base64-encoded signature>"
}
]
}
Verification
WitnessDsseSigner.VerifyWitness performs, in order:
- Check
payloadTypeequalsapplication/vnd.stellaops.witness.v1+json(else reject). - Deserialize the payload into
PathWitness. - Check
witness_schemaequalsstellaops.witness.v1(else reject as unsupported schema). - Run
ValidateDeterministicSymbolization()— runtime/confirmed witnesses must carry a validsymbolizationtuple. - Locate the signature whose
keyidmatches the supplied public key, then verify it viaEnvelopeSignatureService.VerifyDsse.
Verification does not recompute
witness_hashorwitness_id; it validates payload type, schema version, symbolization, and the signature. Transparency-log inclusion is out of scope of the signer/verifier and is not performed here.
Storage
Source of truth:
src/Scanner/__Libraries/StellaOps.Scanner.Persistence/Migrations/001_scanner_consolidated_baseline.sql(table),013_tenant_qualify_witnesses.sql(tenant column, per-tenant uniqueness and lookup indexes) and014_witness_scan_id_text.sql(the scan id as the Scanner scan id text), applied by Scanner Web on startup. All three are idempotent.
Witnesses are stored in the scanner.witnesses table. Note this row uses the predicate-style fields (witness_hash, witness_type, graph_hash, created_at) alongside the full wire payload:
| Column | Type | Description |
|---|---|---|
witness_id | UUID | Primary key (gen_random_uuid() default). This is the DB row ID — not the wire wit:sha256: content address |
tenant_id | TEXT | Owning Scanner tenant, trimmed and lower case (ck_witnesses_tenant_normalized); NOT NULL, no default |
witness_hash | TEXT | Witness payload hash; unique per tenant (uk_witnesses_tenant_witness_hash on (tenant_id, witness_hash)) |
schema_version | TEXT | Default stellaops.witness.v1 |
witness_type | TEXT | reachability_path, gate_proof, etc. |
graph_hash | TEXT | Source rich-graph reference (NOT NULL) |
scan_id | TEXT | Scanner scan id of the producing scan (scanner.scan_runtime_state.scan_id), stored trimmed and never blank (ck_witnesses_scan_id_trimmed); NULL when no scan produced the witness. Migration 014 converted the earlier UUID column, keeping each stored UUID as its canonical text |
run_id | UUID | Nullable |
payload_json | JSONB | Full PathWitness wire JSON |
dsse_envelope | JSONB | Signed envelope (nullable until signed) |
created_at | TIMESTAMPTZ | Default now() |
signed_at | TIMESTAMPTZ | Nullable |
signer_key_id | TEXT | Nullable |
entrypoint_fqn | TEXT | Lookup-by-entrypoint (nullable) |
sink_cve | TEXT | Lookup-by-CVE (nullable) |
Indexes: ix_witnesses_tenant_scan_id and ix_witnesses_tenant_sink_cve (both partial, on tenant_id then the lookup column and created_at DESC), ix_witnesses_tenant_graph_hash, ix_witnesses_entrypoint (partial), ix_witnesses_created_at (DESC), and a GIN index ix_witnesses_payload_gin on payload_json.
Tenant boundary. Every repository statement (PostgresWitnessRepository) names the tenant: reads by id, hash, scan, CVE or graph hash return only that tenant’s witnesses, and signing updates and verification records apply only to a witness the tenant owns. Two tenants that scan the same bytes may each hold a witness with the same witness_hash. Migration 013 attributes rows written before it from the exact scanner.scan_runtime_state tenant of their scan, then the sole tenant observed in Scanner history (or default when none is observed), and aborts rather than guess when several tenants are observed.
A companion audit table scanner.witness_verifications records each verification attempt (verification_id, witness_id FK, verified_at, verified_by, verification_status, verification_error, verifier_key_id).
Producer
Source of truth:
src/Scanner/StellaOps.Scanner.Worker/Processing/Reachability/ReachabilityWitnessRecorder.cs,ReachabilityWitnessStageExecutor.csandScanReachabilityEvidenceJobProcessor.cs(same folder).
The Scanner worker writes scanner.witnesses; no other service and no API route does.
- When. The scan’s
reachability-evidencestage runs the reachability evidence job for every CVE and component with a sink mapping, and the job builds onePathWitnessper entrypoint-to-sink path it finds (ReachabilityEvidenceJobResult.Witnesses; the public verdict object references the same witnesses bywitness_idandpath_hash). Thepublish-evidencestage then stores them. The on-demand scan-level job (the scan’scompute-reachabilityroute) stores the witnesses of its completed child jobs the same way. A job that finds no path builds no witness, so a scan without reachability evidence stores no row. - Tenant and scan. The tenant is the one Scanner records for the scan in
scanner.scan_runtime_state, the tenant the scan read routes authorize against; job lease metadata is not consulted.scan_idis the scan’s own id. A scan without a recorded tenant stores no witness and the worker logs a warning; no tenant is guessed or defaulted. - Row.
payload_jsonholds the canonical wire payload (the bytesWitnessDsseSignersigns),witness_hashthe content hash over those bytes (see Witness ID),witness_typereachability_path,graph_hashthe payload’sevidence.callgraph_digest,entrypoint_fqnthe entrypoint name,sink_cvethe vulnerability id andcreated_atthe witnessobserved_at. Rows are stored unsigned (dsse_envelopeNULL): the scan path holds no witness signing key, and the verify route reports such a witness asunsigned. - Idempotency. Recording is idempotent per tenant and scan: a witness whose
witness_idthe scan already holds is not stored again, so a re-run of the same scan adds only witnesses it has not recorded and removes none. - Failure. A storage failure fails the
publish-evidencestage (the scan is retried or failed like any other evidence write) or, for the on-demand job, marks that job failed with the reason; it is never reported as success. - Limit. Under a fixed worker clock (
Determinism.FixedClock), two scans of the same image build byte-identical witnesses; the per-tenant hash uniqueness then keeps the row of the first scan, and the later scan does not list it under its own scan id. - Limit: re-runs under one scan id. Recording never removes a row, and
run_idstays NULL. The scan id is derived from the submission (tenant, reference, digest, force flag, client request id and metadata), and a forced resubmission of an existing scan id runs that scan again under the same id. When the new run builds a different set of witnesses (for example a tag-only reference whose tag now names other bytes, or a run that no longer finds a path), the earlier run’s witnesses stay listed under the samescanIdnext to the new ones, while the scan’s evidence projection and public verdict show only the latest run. The payload’sartifact.sbom_digestandobserved_attell the runs apart.
API Endpoints
Source of truth:
src/Scanner/StellaOps.Scanner.WebService/Endpoints/WitnessEndpoints.cs, mounted under the API base path/api/v1(ScannerWebServiceOptions.Api.BasePath). All endpoints require thescanner.scans.readauthorization policy (ScannerPolicies.ScansRead) and are scoped to the caller’s tenant.
| Method | Path | Description |
|---|---|---|
GET | /api/v1/witnesses/{witnessId:guid} | Get witness by row ID (UUID) |
GET | /api/v1/witnesses/by-hash/{witnessHash} | Get witness by witness_hash |
GET | /api/v1/witnesses?scanId={scanId} | List witnesses for a scan |
GET | /api/v1/witnesses?cve={cve} | List witnesses for a CVE |
GET | /api/v1/witnesses?graphHash={graphHash} | List witnesses for a graph hash |
POST | /api/v1/witnesses/{witnessId:guid}/verify | Verify witness signature (audited) |
Every route resolves the tenant from the validated
stellaops:tenantclaim (a global-admin principal may select a tenant with the canonicalX-StellaOps-TenantIdheader). A request without a tenant claim returns HTTP 400Invalid tenantwith detailtenant_missing; no default tenant is assumed. A witness id, witness hash,scanId,cveorgraphHashof another tenant answers like an unknown one: the list is empty and the single-witness and verify routes return 404, and no verification row is recorded.The list endpoint returns an empty list when none of
scanId/cve/graphHashis supplied (it deliberately avoids a full-table scan). The path parameter is the DB-row UUIDwitnessId, and the list query parameter isscanId(notscan). There is no create operation in this witness endpoint group — witnesses are produced by the Scanner pipeline, not created over the API (see Producer).scanIdis the Scanner scan id (scanner.scan_runtime_state.scan_id, 40 lower-case hex characters for a submitted scan, the id the scan routes take), not a UUID; it is compared trimmed and case-sensitively, and a blank value returns HTTP 400Invalid scan identifier.
Authorization
The witness endpoints reuse the Scanner read policy scanner.scans.read. There are no witness-specific scopes in the canonical scope catalog (src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs), which defines Scanner scopes such as scanner:read, scanner:scan, scanner:export, and scanner:write. The scanner.scans.read policy/scope name is a Scanner-local constant (ScannerPolicies.ScansRead / ScannerAuthorityScopes.ScansRead) and is not present in the canonical StellaOpsScopes catalog.
CLI
The record/CLI boundary described here was source-checked against 26de00a315978e56c11b562fa2f1df0a85c17310 on 2026-10-14. Re-verify with pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Cli/__Tests/StellaOps.Cli.Tests/StellaOps.Cli.Tests.csproj -Class '*WitnessOwnerContractTests' -BuildProjectReferences -MaxThreads 1.
stella reachability witness-ops wraps Scanner’s persisted-record API through WitnessCommandGroup.cs and CommandHandlers.Witness.cs. Its list command requires exactly one of --scan, --vuln or --graph-hash. Scan IDs are opaque text; show and verify take the persisted record GUID. Supported requests use canonical scanner:read and the existing CLI tenant resolver.
Verdict/tier, reachable-only, probe-type and paging options are unsupported and return non-success before requests. Verification is online through the owner, records verification history, and returns success only for a coherent owner valid result. Offline/public-key modes and the discoverable export command return exit 9 before requests, key access or output-file creation. No JSON, DSSE or SARIF download is produced. Source tests do not authorize a live verification request.
The separate top-level stella witness group operates on binary micro-witness files and is not this API wrapper. See the Scanner witness-record guide for supported output, identity and refusal behavior.
