| 1 | package evidence |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // Receipt kinds classify observed execution facts for display and reporting. |
| 11 | // They do not authorize, settle, or block later operations. |
| 12 | const ( |
| 13 | ReceiptKindRead = "read" |
| 14 | ReceiptKindMutation = "mutation" |
| 15 | ReceiptKindVerification = "verification" |
| 16 | ReceiptKindReview = "review" |
| 17 | ReceiptKindCommand = "command" |
| 18 | ) |
| 19 | |
| 20 | // Receipt is the host-runtime record of one tool call. It stays in memory for |
| 21 | // the current agent turn and is not serialized into prompts or session state. |
| 22 | type Receipt struct { |
| 23 | // ID is a stable host-issued reference for model-facing evidence citations. |
| 24 | ID string `json:"id,omitempty"` |
| 25 | OperationID string `json:"operation_id,omitempty"` |
| 26 | // ToolCallID links UI inspection to the source call without entering prompts. |
| 27 | ToolCallID string `json:"-"` |
| 28 | Interrupted bool `json:"-"` |
| 29 | Sequence uint64 `json:"-"` |
| 30 | ToolName string `json:"tool_name"` |
| 31 | Args json.RawMessage `json:"args,omitempty"` |
| 32 | Profile string `json:"profile,omitempty"` |
| 33 | Success bool `json:"success"` |
| 34 | Command string `json:"command,omitempty"` |
| 35 | Step string `json:"step,omitempty"` |
| 36 | StepProof bool `json:"step_proof,omitempty"` |
| 37 | TodoStep *TodoStepMatch `json:"todo_step,omitempty"` |
| 38 | Paths []string `json:"paths,omitempty"` |
| 39 | Read bool `json:"read,omitempty"` |
| 40 | Write bool `json:"write,omitempty"` |
| 41 | Mutation bool `json:"mutation,omitempty"` |
| 42 | // DeliveryScope separates scratch-only execution from project delivery debt. |
| 43 | // It is turn-local evidence and is never persisted or provider-visible. |
| 44 | DeliveryScope WriteScope `json:"-"` |
| 45 | Todos []TodoItem `json:"todos,omitempty"` |
| 46 | // OutputBytes is the host-observed length of the tool's (redacted, trimmed) |
| 47 | // output. Content-evidence checks require it to be non-zero so a command |
| 48 | // that printed nothing (head -n 0, >/dev/null) can never count as reading. |
| 49 | OutputBytes int `json:"output_bytes,omitempty"` |
| 50 | // OutputDigest is a bounded host-derived identity for the model-visible |
| 51 | // output. Goal progress uses it to distinguish a genuinely changed read or |
| 52 | // command result from an exact successful repeat without retaining content. |
| 53 | OutputDigest string `json:"output_digest,omitempty"` |
| 54 | // ExitCode is the status the child process actually returned. Success only |
| 55 | // says the tool call itself completed, so a failing test run the tool |
| 56 | // reported cleanly stays distinguishable here. Zero differs from unset. |
| 57 | ExitCode *int `json:"exit_code,omitempty"` |
| 58 | // Verification is the host's classification of a shell call: one of the |
| 59 | // Verification* values. Empty means the host never classified this receipt. |
| 60 | Verification string `json:"verification,omitempty"` |
| 61 | // PolicyFloor is the session quality floor in force when this write was |
| 62 | // committed ("delivery" or empty). Host-only replay fact: the contract |
| 63 | // rebuild reads it back so a floor change never rewrites history. |
| 64 | PolicyFloor string `json:"policy_floor,omitempty"` |
| 65 | } |
| 66 | |
| 67 | // ReceiptRef is the bounded, model-safe projection of a host receipt: enough |
| 68 | // to cite a fact by ID, never enough to leak local paths beyond the ones the |
| 69 | // call already named, internal state, or raw output. |
| 70 | type ReceiptRef struct { |
| 71 | ID string `json:"id"` |
| 72 | Kind string `json:"kind"` |
| 73 | Success bool `json:"success"` |
| 74 | OperationID string `json:"operation_id,omitempty"` |
| 75 | Paths []string `json:"paths,omitempty"` |
| 76 | Digest string `json:"digest,omitempty"` |
| 77 | // Summary is display-only. It must never be used as an identity key: that |
| 78 | // is what made shell prefixes and quoting differences reject real work. |
| 79 | Summary string `json:"summary,omitempty"` |
| 80 | } |
| 81 | |
| 82 | // Ref projects the receipt into its citable form. |
| 83 | func (r Receipt) Ref() ReceiptRef { |
| 84 | return ReceiptRef{ |
| 85 | ID: r.ID, |
| 86 | Kind: r.Kind(), |
| 87 | Success: r.Success, |
| 88 | OperationID: r.OperationID, |
| 89 | Paths: append([]string(nil), r.Paths...), |
| 90 | Digest: r.OutputDigest, |
| 91 | Summary: receiptSummary(r), |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | // Kind classifies what a receipt proves. A command is only a verification when |
| 96 | // the host recognized the verifier: an unclassified command stays a command, |
| 97 | // which is a recorded success rather than a rejection. |
| 98 | func (r Receipt) Kind() string { |
| 99 | switch { |
| 100 | case r.Mutation || r.Write: |
| 101 | return ReceiptKindMutation |
| 102 | case r.ToolName == "review_report" || r.ToolName == "review": |
| 103 | return ReceiptKindReview |
| 104 | case r.Command != "": |
| 105 | if r.Verification == VerificationPassed || r.Verification == VerificationFailed { |
| 106 | return ReceiptKindVerification |
| 107 | } |
| 108 | if IsVerificationCommand(r.Command) { |
| 109 | return ReceiptKindVerification |
| 110 | } |
| 111 | return ReceiptKindCommand |
| 112 | case r.Read: |
| 113 | return ReceiptKindRead |
| 114 | default: |
| 115 | return ReceiptKindCommand |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // receiptSummary is a short, bounded label for display. Long commands are |
| 120 | // truncated because nothing matches on this text. |
| 121 | func receiptSummary(r Receipt) string { |
| 122 | summary := strings.TrimSpace(r.Command) |
| 123 | if summary == "" { |
| 124 | summary = r.ToolName |
| 125 | if len(r.Paths) > 0 { |
| 126 | summary += " " + r.Paths[0] |
| 127 | } |
| 128 | } |
| 129 | if runes := []rune(summary); len(runes) > 80 { |
| 130 | summary = string(runes[:80]) + "…" |
| 131 | } |
| 132 | return summary |
| 133 | } |
| 134 | |
| 135 | // ObserveOutput records the trimmed output size and a compact digest without |
| 136 | // retaining model-visible content in the evidence ledger. |
| 137 | func (r *Receipt) ObserveOutput(output string) { |
| 138 | if r == nil { |
| 139 | return |
| 140 | } |
| 141 | trimmed := strings.TrimSpace(output) |
| 142 | r.OutputBytes = len(trimmed) |
| 143 | if trimmed == "" { |
| 144 | r.OutputDigest = "" |
| 145 | return |
| 146 | } |
| 147 | sum := sha256.Sum256([]byte(trimmed)) |
| 148 | r.OutputDigest = fmt.Sprintf("%x", sum[:16]) |
| 149 | } |
| 150 | |
| 151 | // Verification classifications mirror tool.ShellVerification*, duplicated so |
| 152 | // this package keeps importing nothing from the tool layer. |
| 153 | const ( |
| 154 | VerificationNotVerification = "not_verification" |
| 155 | VerificationNotRun = "not_run" |
| 156 | VerificationPassed = "passed" |
| 157 | VerificationFailed = "failed" |
| 158 | ) |
| 159 |