package pipeline import ( "crypto/sha256" "encoding/hex" "fmt" "sort" "strings" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) type CheckpointFingerprint struct { Name string `json:"name"` Value string `json:"value"` } // CheckpointFingerprintProvider supplies stable, non-secret semantic identity // for a prepared module or validator. Values must not contain source content, // credentials, local paths, timestamps, or other invocation-specific data. type CheckpointFingerprintProvider interface { CheckpointFingerprints() []CheckpointFingerprint } type CheckpointRecorder interface { SourceRunning(moduleKey string) error SourceSucceeded(moduleKey string, doc *source.SourceDocument) error SourceFailed(moduleKey string, err error) error ExtractRunning(laneID string, moduleKey string, dependencies []CheckpointFingerprint) error ExtractSucceeded(laneID string, moduleKey string, dependencies []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error ExtractFailed(laneID string, moduleKey string, dependencies []CheckpointFingerprint, err error) error MergeRunning(laneID string, moduleKey string, dependencies []CheckpointFingerprint) error MergeSucceeded(laneID string, moduleKey string, dependencies []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error MergeRejected(laneID string, moduleKey string, dependencies []CheckpointFingerprint, rejected contracts.RejectedOutput) error MergeFailed(laneID string, moduleKey string, dependencies []CheckpointFingerprint, err error) error NormalizeRunning(laneID string, moduleKey string, dependencies []CheckpointFingerprint) error NormalizeSucceeded(laneID string, moduleKey string, dependencies []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error NormalizeRejected(laneID string, moduleKey string, dependencies []CheckpointFingerprint, rejected contracts.RejectedOutput) error NormalizeFailed(laneID string, moduleKey string, dependencies []CheckpointFingerprint, err error) error } // StepCheckpointRecorder is implemented by checkpoint stores that isolate // lane artifacts by their ordered pipeline step. The legacy recorder methods // remain available for callers that do not have step context. type StepCheckpointRecorder interface { ExtractRunningForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint) error ExtractSucceededForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error ExtractFailedForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, err error) error MergeRunningForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint) error MergeSucceededForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error MergeRejectedForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, rejected contracts.RejectedOutput) error MergeFailedForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, err error) error NormalizeRunningForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint) error NormalizeSucceededForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error NormalizeRejectedForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, rejected contracts.RejectedOutput) error NormalizeFailedForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, err error) error } type CheckpointDecisionCategory string const ( CheckpointDecisionExecuted CheckpointDecisionCategory = "executed" CheckpointDecisionReused CheckpointDecisionCategory = "reused" CheckpointDecisionForcedRecompute CheckpointDecisionCategory = "forced_recompute" CheckpointDecisionDependencyInvalidated CheckpointDecisionCategory = "dependency_invalidated" ) type CheckpointReasonCode string const ( CheckpointReasonLoadingDisabled CheckpointReasonCode = "loading_disabled" CheckpointReasonMissing CheckpointReasonCode = "checkpoint_missing" CheckpointReasonPathInvalid CheckpointReasonCode = "checkpoint_path_invalid" CheckpointReasonReadFailed CheckpointReasonCode = "checkpoint_read_failed" CheckpointReasonDecodeFailed CheckpointReasonCode = "checkpoint_decode_failed" CheckpointReasonWorkspaceSchemaIncompatible CheckpointReasonCode = "workspace_schema_incompatible" CheckpointReasonIdentityMismatch CheckpointReasonCode = "identity_mismatch" CheckpointReasonStageMismatch CheckpointReasonCode = "stage_mismatch" CheckpointReasonStepMismatch CheckpointReasonCode = "step_mismatch" CheckpointReasonLaneMismatch CheckpointReasonCode = "lane_mismatch" CheckpointReasonModuleMismatch CheckpointReasonCode = "module_mismatch" CheckpointReasonStatusNotReusable CheckpointReasonCode = "status_not_reusable" CheckpointReasonDependencyMismatch CheckpointReasonCode = "dependency_mismatch" CheckpointReasonArtifactPayloadInvalid CheckpointReasonCode = "artifact_payload_invalid" CheckpointReasonArtifactDigestMismatch CheckpointReasonCode = "artifact_digest_mismatch" CheckpointReasonArtifactCodecIncompatible CheckpointReasonCode = "artifact_codec_incompatible" CheckpointReasonArtifactNotCanonical CheckpointReasonCode = "artifact_not_canonical" CheckpointReasonReused CheckpointReasonCode = "checkpoint_reused" CheckpointReasonAcceptedArtifactReused CheckpointReasonCode = "accepted_artifact_reused" CheckpointReasonRecomputeStep CheckpointReasonCode = "recompute_step" ) type CheckpointDecision struct { Reused bool `json:"reused"` Category CheckpointDecisionCategory `json:"category,omitempty"` ReasonCode CheckpointReasonCode `json:"reason_code,omitempty"` Detail string `json:"detail,omitempty"` // Reason is retained as a compatibility/debug field for existing callers. // Detail and Reason are derived from the stable reason code. Reason string `json:"reason,omitempty"` } const checkpointDecisionDetailLimit = 512 func NewCheckpointDecision(category CheckpointDecisionCategory, reasonCode CheckpointReasonCode) CheckpointDecision { return checkpointDecision(category, reasonCode) } func checkpointDecision(category CheckpointDecisionCategory, reasonCode CheckpointReasonCode) CheckpointDecision { detail := normalizeCheckpointDecisionDetail(checkpointDecisionDetail(reasonCode)) return CheckpointDecision{ Reused: category == CheckpointDecisionReused, Category: category, ReasonCode: reasonCode, Detail: detail, Reason: detail, } } func checkpointDecisionDetail(reasonCode CheckpointReasonCode) string { switch reasonCode { case CheckpointReasonLoadingDisabled: return "checkpoint loading is disabled" case CheckpointReasonMissing: return "checkpoint artifact is missing" case CheckpointReasonPathInvalid: return "checkpoint location is invalid" case CheckpointReasonReadFailed: return "checkpoint artifact could not be read" case CheckpointReasonDecodeFailed: return "checkpoint artifact could not be decoded" case CheckpointReasonWorkspaceSchemaIncompatible: return "checkpoint workspace schema is incompatible" case CheckpointReasonIdentityMismatch: return "checkpoint identity does not match the current invocation" case CheckpointReasonStageMismatch: return "checkpoint stage does not match" case CheckpointReasonStepMismatch: return "checkpoint step does not match" case CheckpointReasonLaneMismatch: return "checkpoint lane does not match" case CheckpointReasonModuleMismatch: return "checkpoint module does not match" case CheckpointReasonStatusNotReusable: return "checkpoint status is not reusable" case CheckpointReasonDependencyMismatch: return "checkpoint dependencies do not match" case CheckpointReasonArtifactPayloadInvalid: return "checkpoint artifact payload is invalid" case CheckpointReasonArtifactDigestMismatch: return "checkpoint artifact digest does not match" case CheckpointReasonArtifactCodecIncompatible: return "checkpoint artifact is incompatible with the registered codec" case CheckpointReasonArtifactNotCanonical: return "checkpoint artifact is not canonical" case CheckpointReasonReused: return "checkpoint is reusable" case CheckpointReasonAcceptedArtifactReused: return "accepted normalized artifact is reusable" case CheckpointReasonRecomputeStep: return "selected step requires execution" default: return "checkpoint decision" } } func normalizeCheckpointDecisionDetail(detail string) string { detail = strings.TrimSpace(strings.ToValidUTF8(detail, "?")) var b strings.Builder for _, r := range detail { if r < 0x20 || r == 0x7f { r = ' ' } if b.Len()+len(string(r)) > checkpointDecisionDetailLimit { break } b.WriteRune(r) } return strings.Join(strings.Fields(b.String()), " ") } type CheckpointEvent struct { Stage string `json:"stage"` StepID string `json:"step_id,omitempty"` LaneID string `json:"lane_id,omitempty"` ModuleKey string `json:"module_key,omitempty"` Action CheckpointDecisionCategory `json:"action"` Category CheckpointDecisionCategory `json:"category,omitempty"` ReasonCode CheckpointReasonCode `json:"reason_code,omitempty"` Detail string `json:"detail,omitempty"` Reason string `json:"reason,omitempty"` } type CheckpointExecutionPolicy struct { ForcedLanes map[string]struct{} RequireReusableLanes map[string]struct{} } func CheckpointLaneKey(stepID, laneID string) string { return strings.TrimSpace(stepID) + "\x00" + strings.TrimSpace(laneID) } func (policy CheckpointExecutionPolicy) forced(stepID, laneID string) bool { _, ok := policy.ForcedLanes[CheckpointLaneKey(stepID, laneID)] return ok } func (policy CheckpointExecutionPolicy) requiresReusable(stepID, laneID string) bool { _, ok := policy.RequireReusableLanes[CheckpointLaneKey(stepID, laneID)] return ok } func forceCheckpointDecision(policy CheckpointExecutionPolicy, stepID, laneID string, decision CheckpointDecision) CheckpointDecision { if policy.forced(stepID, laneID) { return checkpointDecision(CheckpointDecisionForcedRecompute, CheckpointReasonRecomputeStep) } return decision } func requireReusableCheckpoint(policy CheckpointExecutionPolicy, stepID, laneID string, decision CheckpointDecision) error { if policy.requiresReusable(stepID, laneID) && !policy.forced(stepID, laneID) && !decision.Reused { return fmt.Errorf("required reusable checkpoint unavailable for step %q lane %q (%s)", strings.TrimSpace(stepID), strings.TrimSpace(laneID), decision.ReasonCode) } return nil } // resolveCheckpointDecision applies runner policy and canonical payload // validation at the single point where a stage's observable decision is made. type checkpointResolution struct { decision CheckpointDecision values []any artifacts []CheckpointArtifact } func resolveCheckpointDecision(output *RunOutput, loader CheckpointLoader, policy CheckpointExecutionPolicy, stage ModuleStage, stepID, laneID, moduleKey string, decision CheckpointDecision, codec artifactCodecEntry, artifacts []CheckpointArtifact) (checkpointResolution, error) { decision = forceCheckpointDecision(policy, stepID, laneID, decision) resolution := checkpointResolution{decision: decision} if decision.Reused { resolution.values = make([]any, 0, len(artifacts)) resolution.artifacts = make([]CheckpointArtifact, 0, len(artifacts)) for _, artifact := range artifacts { value, hydrated, err := decodeCanonicalCheckpointArtifact(codec, artifact) if err != nil { decision = checkpointDecision(CheckpointDecisionExecuted, checkpointArtifactReasonCode(err)) resolution.values = nil resolution.artifacts = nil break } resolution.values = append(resolution.values, value) resolution.artifacts = append(resolution.artifacts, hydrated) } } resolution.decision = decision if output != nil { recordCheckpointEvent(output, loader, string(stage), stepID, laneID, moduleKey, decision) } if err := requireReusableCheckpoint(policy, stepID, laneID, decision); err != nil { return resolution, err } return resolution, nil } type SourceCheckpoint struct { Document *source.SourceDocument } // CheckpointArtifact is the durable, domain-neutral value stored at a lane // checkpoint boundary. type CheckpointArtifact struct { LaneID string ModuleKey string SourceID string ChunkID string ChunkIndex int ChunkRef source.SourceRef Artifact contracts.SerializedArtifact SchemaDigest string } type ExtractCheckpoint struct { Outputs []CheckpointArtifact Rejected []contracts.RejectedOutput Warnings []contracts.Warning } type MergeCheckpoint struct { Output CheckpointArtifact Warnings []contracts.Warning } type NormalizeCheckpoint struct { Output CheckpointArtifact Warnings []contracts.Warning } type CheckpointLoader interface { Enabled() bool Source(moduleKey string) (SourceCheckpoint, CheckpointDecision) Extract(laneID string, moduleKey string, dependencies []CheckpointFingerprint) (ExtractCheckpoint, CheckpointDecision) Merge(laneID string, moduleKey string, dependencies []CheckpointFingerprint) (MergeCheckpoint, CheckpointDecision) Normalize(laneID string, moduleKey string, dependencies []CheckpointFingerprint) (NormalizeCheckpoint, CheckpointDecision) AcceptedNormalize(stepID, laneID, moduleKey string) (NormalizeCheckpoint, CheckpointDecision) } // StepCheckpointLoader is the step-aware counterpart used by the persistent // checkpoint implementation. Loaders without this optional interface remain // usable by framework callers and test doubles through the legacy methods. type StepCheckpointLoader interface { ExtractForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint) (ExtractCheckpoint, CheckpointDecision) MergeForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint) (MergeCheckpoint, CheckpointDecision) NormalizeForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint) (NormalizeCheckpoint, CheckpointDecision) } type noopCheckpointRecorder struct{} type noopCheckpointLoader struct{} func NoopCheckpointRecorder() CheckpointRecorder { return noopCheckpointRecorder{} } func NoopCheckpointLoader() CheckpointLoader { return noopCheckpointLoader{} } func (noopCheckpointRecorder) SourceRunning(string) error { return nil } func (noopCheckpointRecorder) SourceSucceeded(string, *source.SourceDocument) error { return nil } func (noopCheckpointRecorder) SourceFailed(string, error) error { return nil } func (noopCheckpointRecorder) ExtractRunning(string, string, []CheckpointFingerprint) error { return nil } func (noopCheckpointRecorder) ExtractSucceeded(string, string, []CheckpointFingerprint, []CheckpointArtifact, []contracts.RejectedOutput, []contracts.Warning) error { return nil } func (noopCheckpointRecorder) ExtractFailed(string, string, []CheckpointFingerprint, error) error { return nil } func (noopCheckpointRecorder) MergeRunning(string, string, []CheckpointFingerprint) error { return nil } func (noopCheckpointRecorder) MergeSucceeded(string, string, []CheckpointFingerprint, CheckpointArtifact, []contracts.Warning) error { return nil } func (noopCheckpointRecorder) MergeRejected(string, string, []CheckpointFingerprint, contracts.RejectedOutput) error { return nil } func (noopCheckpointRecorder) MergeFailed(string, string, []CheckpointFingerprint, error) error { return nil } func (noopCheckpointRecorder) NormalizeRunning(string, string, []CheckpointFingerprint) error { return nil } func (noopCheckpointRecorder) NormalizeSucceeded(string, string, []CheckpointFingerprint, CheckpointArtifact, []contracts.Warning) error { return nil } func (noopCheckpointRecorder) NormalizeRejected(string, string, []CheckpointFingerprint, contracts.RejectedOutput) error { return nil } func (noopCheckpointRecorder) NormalizeFailed(string, string, []CheckpointFingerprint, error) error { return nil } func (noopCheckpointLoader) Enabled() bool { return false } func (noopCheckpointLoader) Source(string) (SourceCheckpoint, CheckpointDecision) { return SourceCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled) } func (noopCheckpointLoader) Extract(string, string, []CheckpointFingerprint) (ExtractCheckpoint, CheckpointDecision) { return ExtractCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled) } func (noopCheckpointLoader) Merge(string, string, []CheckpointFingerprint) (MergeCheckpoint, CheckpointDecision) { return MergeCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled) } func (noopCheckpointLoader) Normalize(string, string, []CheckpointFingerprint) (NormalizeCheckpoint, CheckpointDecision) { return NormalizeCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled) } func (noopCheckpointLoader) AcceptedNormalize(string, string, string) (NormalizeCheckpoint, CheckpointDecision) { return NormalizeCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled) } func checkpointExtractRunning(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { return stepAware.ExtractRunningForStep(stepID, laneID, moduleKey, deps) } return recorder.ExtractRunning(laneID, moduleKey, deps) } func checkpointExtractSucceeded(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { return stepAware.ExtractSucceededForStep(stepID, laneID, moduleKey, deps, outputs, rejected, warnings) } return recorder.ExtractSucceeded(laneID, moduleKey, deps, outputs, rejected, warnings) } func checkpointExtractFailed(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, err error) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { return stepAware.ExtractFailedForStep(stepID, laneID, moduleKey, deps, err) } return recorder.ExtractFailed(laneID, moduleKey, deps, err) } func checkpointMergeRunning(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { return stepAware.MergeRunningForStep(stepID, laneID, moduleKey, deps) } return recorder.MergeRunning(laneID, moduleKey, deps) } func checkpointMergeSucceeded(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { return stepAware.MergeSucceededForStep(stepID, laneID, moduleKey, deps, output, warnings) } return recorder.MergeSucceeded(laneID, moduleKey, deps, output, warnings) } func checkpointMergeRejected(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, rejected contracts.RejectedOutput) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { return stepAware.MergeRejectedForStep(stepID, laneID, moduleKey, deps, rejected) } return recorder.MergeRejected(laneID, moduleKey, deps, rejected) } func checkpointMergeFailed(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, err error) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { return stepAware.MergeFailedForStep(stepID, laneID, moduleKey, deps, err) } return recorder.MergeFailed(laneID, moduleKey, deps, err) } func checkpointNormalizeRunning(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { return stepAware.NormalizeRunningForStep(stepID, laneID, moduleKey, deps) } return recorder.NormalizeRunning(laneID, moduleKey, deps) } func checkpointNormalizeSucceeded(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { return stepAware.NormalizeSucceededForStep(stepID, laneID, moduleKey, deps, output, warnings) } return recorder.NormalizeSucceeded(laneID, moduleKey, deps, output, warnings) } func checkpointNormalizeRejected(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, rejected contracts.RejectedOutput) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { return stepAware.NormalizeRejectedForStep(stepID, laneID, moduleKey, deps, rejected) } return recorder.NormalizeRejected(laneID, moduleKey, deps, rejected) } func checkpointNormalizeFailed(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, err error) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { return stepAware.NormalizeFailedForStep(stepID, laneID, moduleKey, deps, err) } return recorder.NormalizeFailed(laneID, moduleKey, deps, err) } func digestFingerprints(name string, digest string) []CheckpointFingerprint { digest = strings.TrimSpace(digest) if digest == "" { return nil } return []CheckpointFingerprint{{Name: name, Value: digest}} } func joinedChunkDigest(chunks []source.Chunk) (string, error) { if len(chunks) == 0 { return "", nil } values := make([]string, 0, len(chunks)) for _, chunk := range chunks { digest, err := source.DigestChunk(chunk) if err != nil { return "", fmt.Errorf("digest chunk %q: %w", chunk.ID, err) } values = append(values, chunk.ID+"="+digest) } sort.Strings(values) sum := sha256.Sum256([]byte(strings.Join(values, "\n"))) return "sha256:" + hex.EncodeToString(sum[:]), nil } func normalizeCheckpointFingerprints(values []CheckpointFingerprint) []CheckpointFingerprint { if len(values) == 0 { return nil } byName := make(map[string]string, len(values)) for _, value := range values { name := strings.TrimSpace(value.Name) fingerprint := strings.TrimSpace(value.Value) if name == "" || fingerprint == "" { continue } byName[name] = fingerprint } if len(byName) == 0 { return nil } names := make([]string, 0, len(byName)) for name := range byName { names = append(names, name) } sort.Strings(names) out := make([]CheckpointFingerprint, 0, len(names)) for _, name := range names { out = append(out, CheckpointFingerprint{Name: name, Value: byName[name]}) } return out } func checkpointContentDigest(content []byte) string { sum := sha256.Sum256(content) return "sha256:" + hex.EncodeToString(sum[:]) }