/**
* ProofBundle - Content-addressable audit trail for disposition decisions.
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
* Task: TRUST-015
*
* The proof bundle captures all inputs, normalization, atom evaluation,
* and decision trace for deterministic replay and audit.
*/
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.TrustLattice;
///
/// Input evidence that was ingested.
///
public sealed record ProofInput
{
///
/// The content-addressable digest of the input.
///
public required string Digest { get; init; }
///
/// The type of input (e.g., "sbom", "vex", "scan", "attestation").
///
public required string Type { get; init; }
///
/// The format of the input (e.g., "CycloneDX", "SPDX", "OpenVEX").
///
public string? Format { get; init; }
///
/// URI/path to the original input.
///
public string? Source { get; init; }
///
/// Timestamp when the input was ingested.
///
public DateTimeOffset IngestedAt { get; init; } = DateTimeOffset.UtcNow;
}
///
/// Normalization trace for a VEX statement.
///
public sealed record NormalizationTrace
{
///
/// The original statement ID.
///
public string? OriginalId { get; init; }
///
/// The VEX format.
///
public required string SourceFormat { get; init; }
///
/// The original status/state value.
///
public string? OriginalStatus { get; init; }
///
/// The original justification value.
///
public string? OriginalJustification { get; init; }
///
/// The generated claim ID.
///
public required string ClaimId { get; init; }
///
/// The atoms that were asserted.
///
public required IReadOnlyList GeneratedAssertions { get; init; }
}
///
/// The atom table showing final values for a subject.
///
public sealed record AtomTable
{
///
/// The subject digest.
///
public required string SubjectDigest { get; init; }
///
/// The subject details.
///
public required Subject Subject { get; init; }
///
/// Atom values with support sets.
///
public required IReadOnlyDictionary Atoms { get; init; }
}
///
/// The decision result for a subject.
///
public sealed record DecisionRecord
{
///
/// The subject digest.
///
public required string SubjectDigest { get; init; }
///
/// The selected disposition.
///
public required Disposition Disposition { get; init; }
///
/// The rule that matched.
///
public required string MatchedRule { get; init; }
///
/// Human-readable explanation.
///
public required string Explanation { get; init; }
///
/// Full decision trace.
///
public required IReadOnlyList Trace { get; init; }
///
/// Detected conflicts.
///
public IReadOnlyList Conflicts { get; init; } = [];
///
/// Detected unknowns.
///
public IReadOnlyList Unknowns { get; init; } = [];
}
///
/// Content-addressable proof bundle for audit and replay.
///
public sealed record ProofBundle
{
///
/// The proof bundle ID (content-addressable).
///
public string? Id { get; init; }
///
/// Proof bundle version for schema evolution.
///
public string Version { get; init; } = "1.0.0";
///
/// Timestamp when the proof bundle was created.
///
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
///
/// The policy bundle used for evaluation.
///
public required string PolicyBundleId { get; init; }
///
/// Policy bundle version.
///
public string? PolicyBundleVersion { get; init; }
///
/// All inputs that were ingested.
///
public required IReadOnlyList Inputs { get; init; }
///
/// Normalization traces for VEX statements.
///
public IReadOnlyList Normalization { get; init; } = [];
///
/// Claims that were generated/ingested.
///
public required IReadOnlyList Claims { get; init; }
///
/// Atom tables for all subjects.
///
public required IReadOnlyList AtomTables { get; init; }
///
/// Decision records for all subjects.
///
public required IReadOnlyList Decisions { get; init; }
///
/// Summary statistics.
///
public ProofBundleStats? Stats { get; init; }
///
/// Computes a content-addressable ID for the proof bundle.
///
public string ComputeId()
{
// Canonicalize and hash
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
};
// Create a canonical form without the Id field
var canonical = new
{
version = Version,
created_at = CreatedAt.ToUnixTimeSeconds(),
policy_bundle_id = PolicyBundleId,
policy_bundle_version = PolicyBundleVersion,
input_digests = Inputs.Select(i => i.Digest).Order().ToList(),
claim_ids = Claims.Select(c => c.Id ?? c.ComputeId()).Order().ToList(),
subject_digests = AtomTables.Select(a => a.SubjectDigest).Order().ToList(),
};
var json = JsonSerializer.Serialize(canonical, options);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
///
/// Creates a proof bundle with computed ID.
///
public ProofBundle WithComputedId() => this with { Id = ComputeId() };
}
///
/// Summary statistics for a proof bundle.
///
public sealed record ProofBundleStats
{
///
/// Total number of inputs.
///
public int InputCount { get; init; }
///
/// Total number of claims.
///
public int ClaimCount { get; init; }
///
/// Total number of subjects.
///
public int SubjectCount { get; init; }
///
/// Number of subjects with conflicts.
///
public int ConflictCount { get; init; }
///
/// Number of subjects with incomplete data.
///
public int IncompleteCount { get; init; }
///
/// Disposition counts.
///
public IReadOnlyDictionary DispositionCounts { get; init; } =
new Dictionary();
}
///
/// Builder for creating proof bundles.
///
public sealed class ProofBundleBuilder
{
private readonly List _inputs = [];
private readonly List _normalization = [];
private readonly List _claims = [];
private readonly List _atomTables = [];
private readonly List _decisions = [];
private string _policyBundleId = "unknown";
private string? _policyBundleVersion;
///
/// Sets the policy bundle.
///
public ProofBundleBuilder WithPolicyBundle(PolicyBundle policy)
{
_policyBundleId = policy.Id ?? "unknown";
_policyBundleVersion = policy.Version;
return this;
}
///
/// Adds an input.
///
public ProofBundleBuilder AddInput(ProofInput input)
{
_inputs.Add(input);
return this;
}
///
/// Adds a normalization trace.
///
public ProofBundleBuilder AddNormalization(NormalizationTrace trace)
{
_normalization.Add(trace);
return this;
}
///
/// Adds a claim.
///
public ProofBundleBuilder AddClaim(Claim claim)
{
_claims.Add(claim);
return this;
}
///
/// Adds an atom table from subject state.
///
public ProofBundleBuilder AddAtomTable(SubjectState state)
{
_atomTables.Add(new AtomTable
{
SubjectDigest = state.SubjectDigest,
Subject = state.Subject,
Atoms = state.ToSnapshot(),
});
return this;
}
///
/// Adds a decision record.
///
public ProofBundleBuilder AddDecision(string subjectDigest, DispositionResult result)
{
_decisions.Add(new DecisionRecord
{
SubjectDigest = subjectDigest,
Disposition = result.Disposition,
MatchedRule = result.MatchedRule,
Explanation = result.Explanation,
Trace = result.Trace,
Conflicts = result.Conflicts,
Unknowns = result.Unknowns,
});
return this;
}
///
/// Builds the proof bundle.
///
public ProofBundle Build()
{
var dispositionCounts = _decisions
.GroupBy(d => d.Disposition)
.ToDictionary(g => g.Key, g => g.Count());
var stats = new ProofBundleStats
{
InputCount = _inputs.Count,
ClaimCount = _claims.Count,
SubjectCount = _atomTables.Count,
ConflictCount = _decisions.Count(d => d.Conflicts.Count > 0),
IncompleteCount = _decisions.Count(d => d.Unknowns.Count > 0),
DispositionCounts = dispositionCounts,
};
var bundle = new ProofBundle
{
PolicyBundleId = _policyBundleId,
PolicyBundleVersion = _policyBundleVersion,
Inputs = _inputs,
Normalization = _normalization,
Claims = _claims,
AtomTables = _atomTables,
Decisions = _decisions,
Stats = stats,
};
return bundle.WithComputedId();
}
}