From 92adc8d79aebe44aa70d277a54a24a63a0e9a5b4 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 20 Aug 2026 09:18:11 +0100 Subject: [PATCH 1/3] Add portable run receipts and deferred export --- .changeset/portable-run-receipts.md | 6 + README.md | 8 +- cmd/yskill/bootstrap.go | 2 + cmd/yskill/bootstrap_test.go | 10 + cmd/yskill/main.go | 350 ++++++- cmd/yskill/main_test.go | 8 +- cmd/yskill/skillmeta.go | 4 + docs/README.md | 1 + docs/reference/cli.md | 50 +- docs/reference/guarantees.md | 7 +- docs/reference/run-receipts.md | 90 ++ docs/reference/sdk-parity.md | 11 +- internal/conformance/conformance_test.go | 12 + internal/engine/engine.go | 252 ++++- internal/engine/engine_test.go | 186 ++++ internal/guard/guard.go | 38 +- internal/outbox/outbox.go | 568 +++++++++++ internal/outbox/outbox_test.go | 186 ++++ internal/protocol/protocol.go | 94 +- internal/protocol/protocol_test.go | 44 + internal/receipt/ir_test.go | 73 ++ internal/receipt/receipt.go | 878 ++++++++++++++++++ internal/receipt/receipt_test.go | 221 +++++ internal/receipt/report.go | 137 +++ internal/receipt/report_test.go | 36 + internal/receipt/store.go | 281 ++++++ internal/receipt/store_test.go | 120 +++ internal/runlog/runlog.go | 80 +- ir/README.md | 23 +- .../run-receipt.schema.json | 234 +++++ .../2026-08-20-portable-run-receipts.md | 10 + 31 files changed, 3949 insertions(+), 71 deletions(-) create mode 100644 .changeset/portable-run-receipts.md create mode 100644 docs/reference/run-receipts.md create mode 100644 internal/outbox/outbox.go create mode 100644 internal/outbox/outbox_test.go create mode 100644 internal/receipt/ir_test.go create mode 100644 internal/receipt/receipt.go create mode 100644 internal/receipt/receipt_test.go create mode 100644 internal/receipt/report.go create mode 100644 internal/receipt/report_test.go create mode 100644 internal/receipt/store.go create mode 100644 internal/receipt/store_test.go create mode 100644 ir/yield.observation.v1/run-receipt.schema.json create mode 100644 release-notes/2026-08-20-portable-run-receipts.md diff --git a/.changeset/portable-run-receipts.md b/.changeset/portable-run-receipts.md new file mode 100644 index 0000000..7da71b9 --- /dev/null +++ b/.changeset/portable-run-receipts.md @@ -0,0 +1,6 @@ +--- +"@operatorstack/yield": minor +--- + +Add portable privacy-safe run receipts, durable local materialization, deferred +command-sink export, retry status, and local receipt reports. diff --git a/README.md b/README.md index 38202b4..da83041 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,11 @@ compatibility aliases for `yskill helper install`. 3. The coding agent, user, or CLI supplies the result. 4. Yield resumes from the journal and replays the program to the next operation. +At each stopping point, Yield also stores a privacy-safe portable receipt from +the exact journal prefix. Receipt export is deferred and never adds network +work to foreground execution. See +[portable run receipts](https://github.com/operatorstack/yield/blob/main/docs/reference/run-receipts.md). + If replay produces a different operation, the run fails instead of silently forking. Every side effect crosses one of these primitives: @@ -300,7 +305,8 @@ Run `yskill agents` to inspect the pinned registry and available project paths. Yield provides deterministic control flow, typed requests and responses, persistent run state, replay with divergence detection, stale and duplicate -response rejection, and evidence-bound completion. +response rejection, evidence-bound completion, and deterministic local run +receipts with independent retryable export. Schema validity is not truth. Yield cannot prove that an agent performed only the requested work. `runCommand` is different: the Yield CLI executes the diff --git a/cmd/yskill/bootstrap.go b/cmd/yskill/bootstrap.go index 15981f9..4fc1402 100644 --- a/cmd/yskill/bootstrap.go +++ b/cmd/yskill/bootstrap.go @@ -273,6 +273,8 @@ func bootstrapOperations(plan bootstrapPlan) []bootstrapOperation { operations = append(operations, bootstrapOperation{kind: bootstrapOperationCommand, dir: plan.SkillDir, name: "npm", args: []string{"install", "--ignore-scripts", "--no-audit", "--no-fund"}}) case "go": operations = append(operations, bootstrapOperation{kind: bootstrapOperationCommand, dir: plan.SkillDir, name: "go", args: []string{"mod", "tidy"}}) + case "rust": + operations = append(operations, bootstrapOperation{kind: bootstrapOperationCommand, dir: plan.SkillDir, name: "cargo", args: []string{"generate-lockfile"}}) } operations = append(operations, bootstrapOperation{kind: bootstrapOperationDoctor}, diff --git a/cmd/yskill/bootstrap_test.go b/cmd/yskill/bootstrap_test.go index 23d33e0..9345fcc 100644 --- a/cmd/yskill/bootstrap_test.go +++ b/cmd/yskill/bootstrap_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "strings" "testing" @@ -67,6 +68,13 @@ func TestBootstrapDryRunDoesNotWrite(t *testing.T) { } } +func TestRustBootstrapGeneratesLockBeforeDoctor(t *testing.T) { + operations := bootstrapOperations(bootstrapPlan{Language: "rust", SkillDir: "skills/helper"}) + if len(operations) < 2 || operations[0].kind != bootstrapOperationCommand || operations[0].name != "cargo" || !reflect.DeepEqual(operations[0].args, []string{"generate-lockfile"}) || operations[1].kind != bootstrapOperationDoctor { + t.Fatalf("rust bootstrap operations = %+v", operations) + } +} + func TestHelperInstallUsesBootstrapContract(t *testing.T) { withBootstrapTestState(t) root := t.TempDir() @@ -448,6 +456,8 @@ func TestBuilderModeFixturesAcrossLanguages(t *testing.T) { t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) case "go": runTestCommand(t, dir, "go", "mod", "tidy") + case "rust": + runTestCommand(t, dir, "cargo", "generate-lockfile") } if language == "go" || language == "rust" { runtimePath := localRuntimePath(root) diff --git a/cmd/yskill/main.go b/cmd/yskill/main.go index 832e905..b4ccd50 100644 --- a/cmd/yskill/main.go +++ b/cmd/yskill/main.go @@ -5,6 +5,7 @@ package main import ( "bytes" + "context" "encoding/json" "errors" "flag" @@ -20,7 +21,9 @@ import ( "time" "github.com/operatorstack/yield/internal/engine" + "github.com/operatorstack/yield/internal/outbox" "github.com/operatorstack/yield/internal/protocol" + "github.com/operatorstack/yield/internal/receipt" "github.com/operatorstack/yield/internal/runlog" ) @@ -41,11 +44,21 @@ Usage: yskill doctor check package, skill workflow, and adapters [--agent cursor,codex,...|auto] [--root repo] [--test] yskill run [--input file] start a run; prints the first operation envelope + [--experiment file] yskill resume --response file feed a response; prints the next operation [--skill dir] [--accept-new-digest] yskill respond --value text answer the pending question directly [--result-json json|-] [--skill dir] yskill inspect [--skill dir] print the run's event log + yskill receipt [--skill dir] derive and print a portable receipt + yskill receipt materialize |--all durably store local receipt objects + [--skill dir] + yskill outbox enqueue |--all-terminal --sink id [--skill dir] + yskill outbox deliver --sink id [--skill dir] -- + yskill outbox status [--sink id] [--skill dir] + yskill outbox retry |--failed|--unknown --sink id [--skill dir] + yskill report [--from time] [--to time] [--experiment id] + [--open-age-threshold duration] [--format table|json] yskill prune --older-than 720h remove old terminal runs [--keep-last n] [--dry-run] yskill replay [--skill dir] re-derive the run from its log; verify determinism @@ -114,6 +127,12 @@ func main() { err = cmdRespond(os.Args[2:]) case "inspect": err = cmdInspect(os.Args[2:]) + case "receipt": + err = cmdReceipt(os.Args[2:]) + case "outbox": + err = cmdOutbox(os.Args[2:]) + case "report": + err = cmdReport(os.Args[2:]) case "prune": err = cmdPrune(os.Args[2:]) case "replay": @@ -149,6 +168,7 @@ func cmdHelper(args []string) error { func cmdRun(args []string) error { fs := flag.NewFlagSet("run", flag.ExitOnError) input := fs.String("input", "", "path to a JSON input file") + experimentPath := fs.String("experiment", "", "path to experiment metadata JSON") if err := parseOnePositional(fs, args); err != nil { return err } @@ -167,7 +187,18 @@ func cmdRun(args []string) error { } in = b } - p, err := e.StartRun(in) + var experiment *receipt.ExperimentContext + if *experimentPath != "" { + raw, readErr := os.ReadFile(*experimentPath) + if readErr != nil { + return readErr + } + experiment, err = receipt.DecodeExperiment(raw) + if err != nil { + return err + } + } + p, err := e.StartRunWithOptions(in, engine.StartOptions{Experiment: experiment}) if err != nil { return err } @@ -275,6 +306,318 @@ func cmdInspect(args []string) error { return nil } +func cmdReceipt(args []string) error { + if len(args) > 0 && args[0] == "materialize" { + return cmdReceiptMaterialize(args[1:]) + } + fs := flag.NewFlagSet("receipt", flag.ExitOnError) + skillDir := fs.String("skill", ".", "skill directory the run belongs to") + if err := parseOnePositional(fs, args); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("receipt takes exactly one run id") + } + e, err := newEngine(*skillDir) + if err != nil { + return err + } + _, raw, err := e.Receipt(fs.Arg(0)) + if err != nil { + return err + } + _, err = os.Stdout.Write(append(raw, '\n')) + return err +} + +func cmdReceiptMaterialize(args []string) error { + fs := flag.NewFlagSet("receipt materialize", flag.ExitOnError) + skillDir := fs.String("skill", ".", "skill directory the run belongs to") + all := fs.Bool("all", false, "materialize every run") + if err := parseOnePositional(fs, args); err != nil { + return err + } + if *all == (fs.NArg() == 1) { + return fmt.Errorf("receipt materialize takes one run id or --all") + } + e, err := newEngine(*skillDir) + if err != nil { + return err + } + ids := []string{fs.Arg(0)} + if *all { + ids, err = e.ListRuns() + if err != nil { + return err + } + } + for _, id := range ids { + r, _, materializeErr := e.MaterializeReceipt(id) + if materializeErr != nil { + return materializeErr + } + fmt.Printf("receipt: %s %s\n", id, r.ReceiptDigest) + } + return nil +} + +func cmdOutbox(args []string) error { + if len(args) == 0 { + return fmt.Errorf("outbox requires enqueue, deliver, status, or retry") + } + switch args[0] { + case "enqueue": + return cmdOutboxEnqueue(args[1:]) + case "deliver": + return cmdOutboxDeliver(args[1:]) + case "status": + return cmdOutboxStatus(args[1:]) + case "retry": + return cmdOutboxRetry(args[1:]) + default: + return fmt.Errorf("unknown outbox subcommand %q", args[0]) + } +} + +func cmdOutboxEnqueue(args []string) error { + fs := flag.NewFlagSet("outbox enqueue", flag.ExitOnError) + skillDir := fs.String("skill", ".", "skill directory the run belongs to") + sinkID := fs.String("sink", "", "sink identifier") + allTerminal := fs.Bool("all-terminal", false, "enqueue all terminal receipts") + if err := parseOnePositional(fs, args); err != nil { + return err + } + if *sinkID == "" || *allTerminal == (fs.NArg() == 1) { + return fmt.Errorf("outbox enqueue takes one run id or --all-terminal and requires --sink") + } + e, err := newEngine(*skillDir) + if err != nil { + return err + } + ids := []string{fs.Arg(0)} + if *allTerminal { + ids, err = e.ListRuns() + if err != nil { + return err + } + } + manager := outbox.New(filepath.Dir(e.RunsDir)) + for _, id := range ids { + r, raw, materializeErr := e.MaterializeReceipt(id) + if materializeErr != nil { + return materializeErr + } + if *allTerminal && r.Outcome.Phase != "terminal" && r.Outcome.Phase != "initialization_failed" { + continue + } + if err := manager.Enqueue(*sinkID, r, raw); err != nil { + return err + } + fmt.Printf("outbox: enqueued %s for %s\n", r.ReceiptDigest, *sinkID) + } + return nil +} + +func cmdOutboxDeliver(args []string) error { + separator := -1 + for index, arg := range args { + if arg == "--" { + separator = index + break + } + } + if separator < 0 || separator == len(args)-1 { + return fmt.Errorf("outbox deliver requires -- followed by a sink command") + } + fs := flag.NewFlagSet("outbox deliver", flag.ExitOnError) + skillDir := fs.String("skill", ".", "skill directory the receipts belong to") + sinkID := fs.String("sink", "", "sink identifier") + timeout := fs.Duration("timeout", 5*time.Minute, "timeout for each receipt delivery") + if err := fs.Parse(args[:separator]); err != nil { + return err + } + if *sinkID == "" || fs.NArg() != 0 { + return fmt.Errorf("outbox deliver requires --sink and no positional arguments before --") + } + e, err := newEngine(*skillDir) + if err != nil { + return err + } + statuses, deliverErr := outbox.New(filepath.Dir(e.RunsDir)).Deliver(context.Background(), *sinkID, args[separator+1:], *timeout) + for _, status := range statuses { + fmt.Printf("outbox: %s %s\n", status.ReceiptDigest, status.State) + } + return deliverErr +} + +func cmdOutboxStatus(args []string) error { + fs := flag.NewFlagSet("outbox status", flag.ExitOnError) + skillDir := fs.String("skill", ".", "skill directory the receipts belong to") + sinkID := fs.String("sink", "", "optional sink identifier") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 { + return fmt.Errorf("outbox status takes no positional arguments") + } + e, err := newEngine(*skillDir) + if err != nil { + return err + } + statuses, err := outbox.New(filepath.Dir(e.RunsDir)).Status(*sinkID) + if err != nil { + return err + } + for _, status := range statuses { + fmt.Printf("%-20s %-18s %s attempts=%d", status.SinkID, status.State, status.ReceiptDigest, status.Attempts) + if status.LastCode != "" { + fmt.Printf(" code=%s", status.LastCode) + } + fmt.Println() + } + return nil +} + +func cmdOutboxRetry(args []string) error { + fs := flag.NewFlagSet("outbox retry", flag.ExitOnError) + skillDir := fs.String("skill", ".", "skill directory the receipts belong to") + sinkID := fs.String("sink", "", "sink identifier") + failed := fs.Bool("failed", false, "retry every failed receipt") + unknown := fs.Bool("unknown", false, "retry every delivery-unknown receipt") + if err := parseOnePositional(fs, args); err != nil { + return err + } + selectors := 0 + if fs.NArg() == 1 { + selectors++ + } + if *failed { + selectors++ + } + if *unknown { + selectors++ + } + if *sinkID == "" || selectors != 1 { + return fmt.Errorf("outbox retry requires --sink and one digest, --failed, or --unknown") + } + e, err := newEngine(*skillDir) + if err != nil { + return err + } + manager := outbox.New(filepath.Dir(e.RunsDir)) + digests := []string{fs.Arg(0)} + if *failed || *unknown { + digests = nil + statuses, statusErr := manager.Status(*sinkID) + if statusErr != nil { + return statusErr + } + wanted := "failed" + if *unknown { + wanted = "delivery_unknown" + } + for _, status := range statuses { + if status.State == wanted { + digests = append(digests, status.ReceiptDigest) + } + } + } + for _, digest := range digests { + if err := manager.Retry(*sinkID, digest); err != nil { + return err + } + fmt.Printf("outbox: retry requested for %s\n", digest) + } + return nil +} + +func cmdReport(args []string) error { + fs := flag.NewFlagSet("report", flag.ExitOnError) + fromText := fs.String("from", "", "inclusive RFC3339 start time") + toText := fs.String("to", "", "inclusive RFC3339 end time") + experimentID := fs.String("experiment", "", "experiment identifier") + openAge := fs.Duration("open-age-threshold", 0, "classify open runs older than this query threshold") + format := fs.String("format", "table", "table or json") + if err := parseOnePositional(fs, args); err != nil { + return err + } + if fs.NArg() != 1 || (*format != "table" && *format != "json") || *openAge < 0 { + return fmt.Errorf("report takes one skill directory, a nonnegative threshold, and format table or json") + } + from, err := parseReportTime(*fromText) + if err != nil { + return fmt.Errorf("--from: %w", err) + } + to, err := parseReportTime(*toText) + if err != nil { + return fmt.Errorf("--to: %w", err) + } + if !from.IsZero() && !to.IsZero() && to.Before(from) { + return fmt.Errorf("report --to must not precede --from") + } + e, err := newEngine(fs.Arg(0)) + if err != nil { + return err + } + store := receipt.StoreForRunsDir(e.RunsDir) + ids, err := store.ListRuns() + if err != nil { + return err + } + receipts := make([]*receipt.RunReceipt, 0, len(ids)) + for _, id := range ids { + r, _, loadErr := store.LoadRun(id) + if loadErr != nil { + return loadErr + } + receipts = append(receipts, r) + } + reference := to + if reference.IsZero() { + reference = time.Now().UTC() + } + report, err := receipt.BuildReport(receipts, receipt.ReportOptions{ + From: from, To: to, ExperimentID: *experimentID, OpenAgeThreshold: *openAge, ReferenceTime: reference, + }) + if err != nil { + return err + } + if *format == "json" { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(report) + } + fmt.Printf("receipts: %d\n", report.ReceiptCount) + printNamedCounts("lifecycle", report.Lifecycle) + printNamedCounts("terminal", report.Terminal) + for _, operation := range report.Operations { + fmt.Printf("operation %-12s requested=%d completed=%d total_elapsed_ms=%d\n", operation.Kind, operation.Requested, operation.Completed, operation.TotalElapsedMS) + } + printNamedCounts("response rejection", report.ResponseRejections) + printNamedCounts("requirement", report.Requirements) + fmt.Printf("divergences: %d\n", report.DivergenceCount) + printNamedCounts("runtime group", report.RuntimeGroups) + printNamedCounts("source group", report.SourceGroups) + printNamedCounts("experiment group", report.ExperimentGroups) + if *openAge > 0 { + fmt.Printf("open older than supplied threshold: %d\n", report.OpenOlderThanThreshold) + } + return nil +} + +func parseReportTime(value string) (time.Time, error) { + if value == "" { + return time.Time{}, nil + } + return time.Parse(time.RFC3339, value) +} + +func printNamedCounts(label string, counts []receipt.NamedCount) { + for _, count := range counts { + fmt.Printf("%s %s: %d\n", label, count.Name, count.Count) + } +} + func cmdPrune(args []string) error { fs := flag.NewFlagSet("prune", flag.ExitOnError) olderThan := fs.Duration("older-than", 0, "minimum terminal-run age, for example 24h or 720h") @@ -306,7 +649,7 @@ func cmdPrune(args []string) error { } closed := false for _, event := range log.Events() { - if event.Type == runlog.RunCompleted || event.Type == runlog.RunBlocked || event.Type == runlog.RunRefused { + if event.Type == runlog.RunCompleted || event.Type == runlog.RunBlocked || event.Type == runlog.RunRefused || event.Type == runlog.RunInitializationFailed { closed = true } } @@ -328,6 +671,9 @@ func cmdPrune(args []string) error { } fmt.Printf("prune: %s\n", run.id) if !*dryRun { + if _, _, err := e.MaterializeReceipt(run.id); err != nil { + return fmt.Errorf("prune: preserve receipt for %s: %w", run.id, err) + } if err := os.Remove(filepath.Join(e.RunsDir, run.id+".jsonl")); err != nil { return err } diff --git a/cmd/yskill/main_test.go b/cmd/yskill/main_test.go index 785faad..325be89 100644 --- a/cmd/yskill/main_test.go +++ b/cmd/yskill/main_test.go @@ -132,7 +132,10 @@ func TestPruneRemovesOnlyOldTerminalRuns(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := log.Append(runlog.RunStarted, map[string]any{"run_id": id}); err != nil { + if _, err := log.Append(runlog.RunStarted, map[string]any{ + "run_id": id, + "skill": protocol.SkillRef{Name: filepath.Base(skill), Digest: protocol.DigestBytes([]byte("skill"))}, + }); err != nil { t.Fatal(err) } if closed { @@ -154,6 +157,9 @@ func TestPruneRemovesOnlyOldTerminalRuns(t *testing.T) { if _, err := os.Stat(closed); !os.IsNotExist(err) { t.Fatalf("terminal run was not pruned: %v", err) } + if _, err := os.Stat(filepath.Join(skill, ".yield", "receipts", "runs", "run_closed.ref")); err != nil { + t.Fatalf("terminal receipt was not preserved: %v", err) + } if _, err := os.Stat(active); err != nil { t.Fatalf("active run was pruned: %v", err) } diff --git a/cmd/yskill/skillmeta.go b/cmd/yskill/skillmeta.go index 72ba296..05c713d 100644 --- a/cmd/yskill/skillmeta.go +++ b/cmd/yskill/skillmeta.go @@ -17,6 +17,7 @@ var portableSkillName = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) type skillManifest struct { Version int `json:"version"` YieldVersion string `json:"yield_version"` + SkillVersion string `json:"skill_version,omitempty"` Language string `json:"language"` Run []string `json:"run"` } @@ -41,6 +42,9 @@ func readSkillManifest(dir string) (skillManifest, error) { if !releaseVersionPattern.MatchString(manifest.YieldVersion) { return skillManifest{}, fmt.Errorf("skill.json version 1 requires an exact yield_version") } + if manifest.SkillVersion != "" && !releaseVersionPattern.MatchString(manifest.SkillVersion) { + return skillManifest{}, fmt.Errorf("skill.json skill_version must be an exact semantic version") + } switch manifest.Language { case "typescript", "python", "go", "rust": default: diff --git a/docs/README.md b/docs/README.md index 91efe67..13d28b8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -66,5 +66,6 @@ response and resumes from the next unanswered operation. - [CLI commands](reference/cli.md) - [Coding-agent registration](agent-setup.md) - [Run, pause, resume, and replay](reference/execution-model.md) +- [Portable run receipts and deferred export](reference/run-receipts.md) - [The four SDKs](reference/sdk-parity.md) - [Guarantees and limits](reference/guarantees.md) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2db9133..98ee6fb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -107,11 +107,14 @@ Prints the runtime version and platform. ## `run` ```bash -yskill run [--input input.json] +yskill run [--input input.json] [--experiment experiment.json] ``` Starts a run and prints the first unanswered operation. The run is stored under -the skill's `.yield/runs/` directory. +the skill's `.yield/runs/` directory. Optional experiment metadata uses the +closed `experiment_id`, `cohort_id`, `variant_id`, `role`, +`baseline_variant_id`, and `parent_skill_version` fields. It is observation +metadata and cannot change replay or the workflow result. ## `resume` @@ -169,6 +172,45 @@ yskill replay [--skill directory] Re-executes the program from the log and verifies that recorded operations lead to the same frontier. Operation drift fails loudly. +## `receipt` + +```bash +yskill receipt [--skill directory] +yskill receipt materialize [--skill directory] +yskill receipt materialize --all [--skill directory] +``` + +Derives a portable receipt from one exact journal prefix. The first form prints +without changing local state. `materialize` writes the immutable object and +updates the run reference. New foreground runs materialize automatically. + +## `outbox` + +```bash +yskill outbox enqueue --sink [--skill directory] +yskill outbox enqueue --all-terminal --sink [--skill directory] +yskill outbox deliver --sink [--skill directory] -- +yskill outbox status [--sink ] [--skill directory] +yskill outbox retry --sink [--skill directory] +yskill outbox retry --failed|--unknown --sink [--skill directory] +``` + +Queues and delivers materialized receipts outside foreground execution. Yield +runs the sink argv directly and sends one receipt on standard input. Delivery +is idempotent by receipt digest, retryable, order-independent, and protected by +a per-digest lock. See [portable run receipts](run-receipts.md). + +## `report` + +```bash +yskill report [--from RFC3339] [--to RFC3339] + [--experiment id] [--open-age-threshold duration] [--format table|json] +``` + +Aggregates latest local receipts by lifecycle, terminal disposition, operation +timing, rejection and requirement outcome, runtime and source identity, and +experiment variant. It makes no causal, winner, or activation claim. + ## `test` ```bash @@ -207,4 +249,6 @@ yskill prune --older-than 720h [--keep-last 10] [--dry-run] ``` -Removes old terminal runs. Active runs are never selected. +Removes old terminal runs. Active runs are never selected. Before deletion, +Yield proves that the terminal receipt exists or can be materialized. Receipt +objects and outbox entries are not pruned. diff --git a/docs/reference/guarantees.md b/docs/reference/guarantees.md index 220ce85..581f3e1 100644 --- a/docs/reference/guarantees.md +++ b/docs/reference/guarantees.md @@ -11,7 +11,10 @@ - rejection of undeclared `AskUser` option values; - real command execution by the Yield CLI; - requirements that prevent later completion after failure; -- recorded completed, blocked, and refused outcomes. +- recorded completed, blocked, and refused outcomes; +- deterministic receipt projection from an exact journal prefix; +- durable local receipt materialization before a successful foreground return; +- export state that cannot change the run journal or replay result. ## What remains outside the guarantee @@ -24,6 +27,8 @@ behavioral-equivalence proof for converted prose. - Yield is not a hosted runtime, workflow marketplace, or multi-agent orchestrator. +- Receipt digests support integrity and correlation, not anonymization or proof + of off-protocol agent behavior. Use `RunCommand` for facts the machine can observe, `AskUser` for human authority, and explicit tests for the paths that matter. Runtime and diff --git a/docs/reference/run-receipts.md b/docs/reference/run-receipts.md new file mode 100644 index 0000000..ffa6ea1 --- /dev/null +++ b/docs/reference/run-receipts.md @@ -0,0 +1,90 @@ +# Portable run receipts + +Yield keeps the append-only run journal as the source of truth. At each +foreground stopping point, the supervisor projects the latest journal prefix +into a portable `RunReceipt` and stores it locally before returning success. +Receipts do not participate in replay and cannot change a workflow result. + +The public schema is +`ir/yield.observation.v1/run-receipt.schema.json`. It records structured facts +such as run and skill identity, source and runtime versions when known, +operation kinds and timing, typed rejection and requirement outcomes, +divergence digests, terminal disposition, and optional experiment identifiers. +All four SDKs use the same supervisor projection. + +## Privacy boundary + +Receipts do not contain prompts, instructions, model responses, user answers, +command arguments, stdout, stderr, source contents, credentials, tokens, +environment values, or free-form failure details. Input, result, evidence, +claim, and operation keys are represented by SHA-256 digests. + +Receipts can group runs by recorded source and runtime versions. Installation +paths, package-manager state, adapter state, and environment diagnostics remain +part of `yskill doctor` and are not copied into receipts. + +A digest supports integrity and correlation. It is not anonymization. A party +can guess a low-entropy value and compare its digest, and the same digest can +link observations across receipts. Do not use personal identifiers for +experiment or cohort fields. + +Yield records only supervisor-observed facts. A receipt does not prove what a +coding agent did outside the Yield protocol. + +## Local storage + +```text +.yield/ + runs/.jsonl + receipts/ + objects/sha256//.json + runs/.ref + outbox// + pending/.json + attempts/.jsonl + accepted/.json + locks/.lock +``` + +Receipt objects are immutable and content-addressed. A per-run reference points +to the latest projected prefix. Materialization uses a synced temporary file, +an atomic installation, and a synced reference update. A crash between object +creation and reference update is repaired by materializing the same journal +again. Garbage collection is not part of this release. + +Rust workflows require `Cargo.lock` before a run starts so `cargo run` cannot +create a new source fact after the run is bound. Yield's Rust scaffolds and +developer-helper installer generate this lockfile. + +Run age is query-relative. `yskill report` can say that an open run is older +than a supplied threshold, but it does not declare the run abandoned. + +## Deferred export + +Export is always explicit and separate from foreground execution: + +```bash +yskill outbox enqueue --sink local-analysis --skill ./my-skill +yskill outbox deliver --sink local-analysis --skill ./my-skill -- ./receipt-sink +yskill outbox status --sink local-analysis --skill ./my-skill +yskill outbox retry --failed --sink local-analysis --skill ./my-skill +``` + +Yield executes the sink command directly, without a shell. It provides one +complete receipt on standard input and sets `YIELD_RECEIPT_DIGEST` and +`YIELD_SINK_ID`. Exit zero means the sink accepted responsibility for that +digest. The sink must accept repeated delivery of the same digest +idempotently. + +Yield never stores sink arguments, environment values, stdout, or stderr. A +failed attempt stores only typed process information and optional diagnostic +digests. Credentials belong in the sink's environment or credential store. + +A crash after remote acceptance but before local acceptance is recorded as +`delivery_unknown`. Retrying sends the same digest. Delivery order has no +meaning. A per-digest lock prevents concurrent delivery of one receipt while +allowing unrelated receipts to progress independently. + +Yield supplies observation and experiment primitives. It does not select a +winning variant or grant any consumer authority to rewrite, install, merge, +release, or activate a proposal. diff --git a/docs/reference/sdk-parity.md b/docs/reference/sdk-parity.md index 5c83ab5..3920970 100644 --- a/docs/reference/sdk-parity.md +++ b/docs/reference/sdk-parity.md @@ -13,9 +13,18 @@ contract. Skills declare their language and runner in `skill.json`, for example: ```json -{ "version": 1, "language": "typescript", "run": ["node", "main.ts"] } +{ + "version": 1, + "yield_version": "0.1.38", + "skill_version": "1.0.0", + "language": "typescript", + "run": ["node", "main.ts"] +} ``` +`skill_version` is optional. It identifies the workflow release and is not the +manifest schema `version`. + The conformance suite runs the same workflow in all four languages and compares the observable protocol traces. Language-specific types and syntax differ; run IDs, operation sequencing, digests, responses, requirements, divergence, and diff --git a/internal/conformance/conformance_test.go b/internal/conformance/conformance_test.go index d0f9af2..833f2c4 100644 --- a/internal/conformance/conformance_test.go +++ b/internal/conformance/conformance_test.go @@ -117,6 +117,7 @@ type trace struct { Steps []step Terminal string ReqsPass int + Receipt []string } // runComplete drives the happy path to completion and returns the @@ -191,6 +192,17 @@ func runComplete(t *testing.T, lang language) trace { if !cmdSeen { t.Fatalf("[%s] the run_command operation must appear in the log", lang.name) } + receipt, _, err := e.Receipt(p.RunID) + if err != nil { + t.Fatalf("[%s] receipt projection: %v", lang.name, err) + } + tr.Receipt = append(tr.Receipt, "phase="+receipt.Outcome.Phase, "terminal="+receipt.Outcome.TerminalDisposition) + for _, operation := range receipt.Operations { + tr.Receipt = append(tr.Receipt, fmt.Sprintf("operation=%d:%s:%t", operation.Sequence, operation.Kind, operation.CompletedAt != "")) + } + for _, requirement := range receipt.Requirements { + tr.Receipt = append(tr.Receipt, "requirement="+requirement.Outcome) + } // Replay determinism on the completed run. rp, err := e.Replay(p.RunID) diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 2fe9ffd..8a8ae0e 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -9,10 +9,12 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "os/exec" "path/filepath" + "regexp" "sort" "strings" "time" @@ -20,6 +22,7 @@ import ( "github.com/gofrs/flock" "github.com/operatorstack/yield/internal/guard" "github.com/operatorstack/yield/internal/protocol" + "github.com/operatorstack/yield/internal/receipt" "github.com/operatorstack/yield/internal/runlog" ) @@ -57,7 +60,7 @@ func NewWithRunsDir(skillDir, runsDir string) (*Engine, error) { if err != nil { return nil, err } - if err := os.MkdirAll(runs, 0o755); err != nil { + if err := os.MkdirAll(runs, 0o700); err != nil { return nil, err } return &Engine{SkillDir: abs, RunsDir: runs, Stderr: os.Stderr}, nil @@ -71,26 +74,81 @@ type Progress struct { Terminal *protocol.TerminalOutcome } +// StartOptions are observation metadata. They never alter replay or the +// formal skill result. +type StartOptions struct { + Experiment *receipt.ExperimentContext +} + +// RunError preserves the allocated run identity when foreground work fails. +type RunError struct { + RunID string + Err error +} + +func (e *RunError) Error() string { return fmt.Sprintf("run %s: %v", e.RunID, e.Err) } +func (e *RunError) Unwrap() error { return e.Err } + // StartRun creates a run bound to the current skill digest and advances // to the first agent-facing operation or terminal. func (e *Engine) StartRun(input json.RawMessage) (*Progress, error) { - digest, err := protocol.DigestSkillDir(e.SkillDir) - if err != nil { - return nil, err - } - skill := protocol.SkillRef{Name: filepath.Base(e.SkillDir), Digest: digest} + return e.StartRunWithOptions(input, StartOptions{}) +} + +// StartRunWithOptions creates a journal before fallible initialization so +// initialization failures remain observable. +func (e *Engine) StartRunWithOptions(input json.RawMessage, options StartOptions) (*Progress, error) { runID := newRunID() + lockPath := filepath.Join(e.RunsDir, runID+".lock") + lock := flock.New(lockPath) + if err := lock.Lock(); err != nil { + return nil, &RunError{RunID: runID, Err: fmt.Errorf("lock run: %w", err)} + } + defer func() { _ = lock.Unlock(); _ = lock.Close() }() + _ = os.Chmod(lockPath, 0o600) l, err := runlog.Create(e.RunsDir, runID) if err != nil { - return nil, err + return nil, &RunError{RunID: runID, Err: err} + } + opened := map[string]any{ + "run_id": runID, "skill_name": filepath.Base(e.SkillDir), + "input_digest": protocol.DigestBytes(input), + "supervisor_version": e.SupervisorVersion, + "experiment": options.Experiment, + } + if _, err := l.Append(runlog.RunOpened, opened); err != nil { + return nil, &RunError{RunID: runID, Err: err} + } + skill, requiredVersion, err := e.prepareRun() + if err != nil { + code := initializationCode(err) + if _, appendErr := l.Append(runlog.RunInitializationFailed, map[string]string{"phase": "initialize", "code": code}); appendErr != nil { + err = errors.Join(err, appendErr) + } + if materializeErr := e.materialize(runID); materializeErr != nil { + err = errors.Join(err, fmt.Errorf("materialize receipt: %w", materializeErr)) + } + return nil, &RunError{RunID: runID, Err: err} } if _, err := l.Append(runlog.RunStarted, map[string]any{ "run_id": runID, "skill": skill, - "input_digest": protocol.DigestBytes(input), + "input_digest": protocol.DigestBytes(input), + "supervisor_version": e.SupervisorVersion, + "required_yield_version": requiredVersion, + "source_digest_profile": protocol.SkillSourceProfileV1, + "source_digest": skill.Digest, + "experiment": options.Experiment, }); err != nil { - return nil, err + return nil, &RunError{RunID: runID, Err: err} } - return e.advance(l, runID) + progress, advanceErr := e.advance(l, runID) + if materializeErr := e.materialize(runID); materializeErr != nil { + advanceErr = errors.Join(advanceErr, fmt.Errorf("materialize receipt: %w", materializeErr)) + } + if advanceErr != nil { + return progress, &RunError{RunID: runID, Err: advanceErr} + } + return progress, nil } // Resume validates and accepts a response for the pending operation, then @@ -200,11 +258,14 @@ func (e *Engine) resumeLocked(runID string, respBytes []byte, migrate bool, expe if err != nil { return nil, err } + if s.InitializationFailed { + return nil, fmt.Errorf("run %s failed during initialization and cannot accept responses", runID) + } var resp protocol.ResponseEnvelope if err := json.Unmarshal(respBytes, &resp); err != nil { return nil, fmt.Errorf("response does not decode: %w", err) } - current, err := protocol.DigestSkillDir(e.SkillDir) + current, err := e.currentDigest(s.SourceDigestProfile) if err != nil { return nil, err } @@ -252,8 +313,15 @@ func (e *Engine) withRunLock(runID string, fn func() (*Progress, error)) (*Progr if err := lock.Lock(); err != nil { return nil, fmt.Errorf("lock run %s: %w", runID, err) } + _ = os.Chmod(path, 0o600) defer func() { _ = lock.Unlock(); _ = lock.Close() }() - return fn() + progress, runErr := fn() + if _, statErr := os.Stat(filepath.Join(e.RunsDir, runID+".jsonl")); statErr == nil { + if materializeErr := e.materialize(runID); materializeErr != nil { + runErr = errors.Join(runErr, fmt.Errorf("run %s: materialize receipt: %w", runID, materializeErr)) + } + } + return progress, runErr } // Replay re-executes the program against the full journal and verifies it @@ -271,6 +339,9 @@ func (e *Engine) replayFromLog(l *runlog.Log, runID string) (*Progress, error) { if err != nil { return nil, err } + if s.InitializationFailed { + return nil, fmt.Errorf("run %s failed before program execution and has no replay frontier", runID) + } out, err := e.execute(l, runID) if err != nil { return nil, err @@ -297,6 +368,58 @@ func (e *Engine) Log(runID string) (*runlog.Log, error) { return runlog.Open(e.RunsDir, runID) } +// Receipt derives a receipt from an exact journal prefix without materializing it. +func (e *Engine) Receipt(runID string) (*receipt.RunReceipt, []byte, error) { + path := filepath.Join(e.RunsDir, runID+".lock") + lock := flock.New(path) + if err := lock.Lock(); err != nil { + return nil, nil, fmt.Errorf("lock run %s: %w", runID, err) + } + _ = os.Chmod(path, 0o600) + defer func() { _ = lock.Unlock(); _ = lock.Close() }() + return e.project(runID) +} + +// MaterializeReceipt derives and durably stores the latest receipt. +func (e *Engine) MaterializeReceipt(runID string) (*receipt.RunReceipt, []byte, error) { + path := filepath.Join(e.RunsDir, runID+".lock") + lock := flock.New(path) + if err := lock.Lock(); err != nil { + return nil, nil, fmt.Errorf("lock run %s: %w", runID, err) + } + _ = os.Chmod(path, 0o600) + defer func() { _ = lock.Unlock(); _ = lock.Close() }() + r, raw, err := e.project(runID) + if err != nil { + return nil, nil, err + } + if err := receipt.StoreForRunsDir(e.RunsDir).Put(r, raw); err != nil { + return nil, nil, err + } + return r, raw, nil +} + +func (e *Engine) project(runID string) (*receipt.RunReceipt, []byte, error) { + l, rawJournal, err := runlog.OpenSnapshot(e.RunsDir, runID) + if err != nil { + return nil, nil, err + } + r, err := receipt.Project(receipt.Snapshot{Bytes: rawJournal, Events: l.Events()}) + if err != nil { + return nil, nil, err + } + raw, err := receipt.CanonicalBytes(r) + return r, raw, err +} + +func (e *Engine) materialize(runID string) error { + r, raw, err := e.project(runID) + if err != nil { + return err + } + return receipt.StoreForRunsDir(e.RunsDir).Put(r, raw) +} + // ListRuns returns known run IDs, newest last. func (e *Engine) ListRuns() ([]string, error) { entries, err := os.ReadDir(e.RunsDir) @@ -320,6 +443,9 @@ func (e *Engine) advance(l *runlog.Log, runID string) (*Progress, error) { for { out, err := e.execute(l, runID) if err != nil { + if _, appendErr := l.Append(runlog.ExecutionFailed, map[string]string{"code": executionFailureCode(err)}); appendErr != nil { + return nil, errors.Join(err, appendErr) + } return nil, err } switch out.Type { @@ -344,6 +470,9 @@ func (e *Engine) advance(l *runlog.Log, runID string) (*Progress, error) { if env.Request.Kind == protocol.OpRunCommand { resp, err := e.runCommand(env) if err != nil { + if _, appendErr := l.Append(runlog.ExecutionFailed, map[string]string{"code": "command_execution_failed"}); appendErr != nil { + return nil, errors.Join(err, appendErr) + } return nil, err } if err := e.acceptResponse(l, env, resp); err != nil { @@ -452,6 +581,9 @@ func (e *Engine) execute(l *runlog.Log, runID string) (*protocol.ProgramOutput, cmd.Stderr = e.Stderr outBytes, err := cmd.Output() if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("skill program timeout: %w", context.DeadlineExceeded) + } return nil, fmt.Errorf("skill program failed: %w", err) } out, err := protocol.DecodeProgramOutput(outBytes) @@ -561,7 +693,7 @@ func (e *Engine) terminate(l *runlog.Log, runID string, out *protocol.ProgramOut case protocol.StatusCompleted: if err := guard.CheckCompletion(s, out.Requirements); err != nil { // complete_unproven is forbidden: the run closes blocked, loudly. - if _, aerr := l.Append(runlog.RunBlocked, map[string]string{"reason": err.Error()}); aerr != nil { + if _, aerr := l.Append(runlog.RunBlocked, map[string]string{"cause": "completion_unproven", "reason": err.Error()}); aerr != nil { return nil, aerr } return nil, err @@ -572,7 +704,11 @@ func (e *Engine) terminate(l *runlog.Log, runID string, out *protocol.ProgramOut return nil, err } case protocol.StatusRequirementFailed, protocol.StatusBlocked: - if _, err := l.Append(runlog.RunBlocked, map[string]string{"reason": term.Reason}); err != nil { + cause := "blocked" + if term.Status == protocol.StatusRequirementFailed { + cause = "requirement_failed" + } + if _, err := l.Append(runlog.RunBlocked, map[string]string{"cause": cause, "reason": term.Reason}); err != nil { return nil, err } case protocol.StatusRefused: @@ -588,9 +724,11 @@ func (e *Engine) terminate(l *runlog.Log, runID string, out *protocol.ProgramOut // rejected records a guard refusal in the log and returns it. func (e *Engine) rejected(l *runlog.Log, err error) error { if rej, ok := err.(*guard.Rejection); ok { - _, _ = l.Append(runlog.ResponseRejected, map[string]string{ + if _, appendErr := l.Append(runlog.ResponseRejected, map[string]string{ "reason": string(rej.Reason), "detail": rej.Detail, - }) + }); appendErr != nil { + return errors.Join(err, fmt.Errorf("record response rejection: %w", appendErr)) + } } return err } @@ -602,3 +740,85 @@ func newRunID() string { } return fmt.Sprintf("run_%d_%s", time.Now().UTC().Unix(), hex.EncodeToString(b[:])) } + +type runManifest struct { + Version int `json:"version"` + YieldVersion string `json:"yield_version"` + SkillVersion string `json:"skill_version,omitempty"` + Language string `json:"language"` + Run []string `json:"run"` +} + +var semanticVersion = regexp.MustCompile(`^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$`) + +func (e *Engine) prepareRun() (protocol.SkillRef, string, error) { + manifestPath := filepath.Join(e.SkillDir, "skill.json") + var manifest runManifest + if raw, err := os.ReadFile(manifestPath); err == nil { + if err := json.Unmarshal(raw, &manifest); err != nil { + return protocol.SkillRef{}, "", fmt.Errorf("manifest_invalid: %w", err) + } + if manifest.Version != 1 || !semanticVersion.MatchString(manifest.YieldVersion) || len(manifest.Run) == 0 { + return protocol.SkillRef{}, "", fmt.Errorf("manifest_invalid: skill.json requires version 1, yield_version, and run") + } + if manifest.SkillVersion != "" && !semanticVersion.MatchString(manifest.SkillVersion) { + return protocol.SkillRef{}, "", fmt.Errorf("manifest_invalid: skill_version must be an exact semantic version") + } + switch manifest.Language { + case "typescript", "python", "go", "rust": + default: + return protocol.SkillRef{}, "", fmt.Errorf("manifest_invalid: language is unsupported") + } + if manifest.Language == "rust" { + if _, err := os.Stat(filepath.Join(e.SkillDir, "Cargo.lock")); err != nil { + return protocol.SkillRef{}, "", fmt.Errorf("source_lockfile_missing") + } + } + if e.SupervisorVersion == "" { + return protocol.SkillRef{}, "", fmt.Errorf("runtime_version_missing") + } + if e.SupervisorVersion != "dev" && manifest.YieldVersion != e.SupervisorVersion { + return protocol.SkillRef{}, "", fmt.Errorf("runtime_incompatible") + } + } else if !errors.Is(err, os.ErrNotExist) { + return protocol.SkillRef{}, "", fmt.Errorf("manifest_read_failed: %w", err) + } else if _, statErr := os.Stat(filepath.Join(e.SkillDir, "main.go")); statErr != nil { + return protocol.SkillRef{}, "", fmt.Errorf("runner_missing") + } + digest, err := protocol.DigestSkillDirProfile(e.SkillDir, protocol.SkillSourceProfileV1) + if err != nil { + return protocol.SkillRef{}, "", fmt.Errorf("source_digest_failed: %w", err) + } + return protocol.SkillRef{Name: filepath.Base(e.SkillDir), Version: manifest.SkillVersion, Digest: digest}, manifest.YieldVersion, nil +} + +func (e *Engine) currentDigest(profile string) (string, error) { + if profile == "" { + return protocol.DigestSkillDir(e.SkillDir) + } + return protocol.DigestSkillDirProfile(e.SkillDir, profile) +} + +func initializationCode(err error) string { + text := err.Error() + for _, code := range []string{"manifest_invalid", "manifest_read_failed", "runtime_version_missing", "runtime_incompatible", "runner_missing", "source_lockfile_missing", "source_digest_failed"} { + if strings.HasPrefix(text, code) { + return code + } + } + return "initialization_failed" +} + +func executionFailureCode(err error) string { + var invalid *protocol.InvalidProgramOutputError + switch { + case errors.As(err, &invalid): + return "invalid_program_output" + case errors.Is(err, context.DeadlineExceeded): + return "execution_timeout" + case strings.Contains(err.Error(), "skill program failed"): + return "subprocess_failed" + default: + return "execution_failed" + } +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 59b76b1..120e859 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -2,16 +2,22 @@ package engine import ( "bytes" + "context" "encoding/json" + "errors" "os" "os/exec" "path/filepath" + "runtime" "strings" "sync" "testing" + "time" "github.com/operatorstack/yield/internal/guard" + "github.com/operatorstack/yield/internal/outbox" "github.com/operatorstack/yield/internal/protocol" + "github.com/operatorstack/yield/internal/receipt" "github.com/operatorstack/yield/internal/runlog" ) @@ -26,6 +32,150 @@ func testEngine(t *testing.T, skill string) *Engine { return &Engine{SkillDir: abs, RunsDir: t.TempDir(), Stderr: os.Stderr} } +func TestStartRunMaterializesReceiptBeforeReturn(t *testing.T) { + e := testEngine(t, "skill-basic") + p, err := e.StartRun(nil) + if err != nil { + t.Fatal(err) + } + r, _, err := receipt.StoreForRunsDir(e.RunsDir).LoadRun(p.RunID) + if err != nil { + t.Fatal(err) + } + if r.Outcome.Phase != "awaiting_response" || r.Skill.SourceDigest == nil || r.Skill.SourceDigest.Profile != protocol.SkillSourceProfileV1 { + t.Fatalf("unexpected materialized receipt: %+v", r) + } +} + +func TestExperimentMetadataIsObservedButNotInReplayJournal(t *testing.T) { + e := testEngine(t, "skill-basic") + experiment := &receipt.ExperimentContext{ExperimentID: "exp-1", VariantID: "candidate-a", Role: "candidate", BaselineVariantID: "baseline-a"} + p, err := e.StartRunWithOptions(nil, StartOptions{Experiment: experiment}) + if err != nil { + t.Fatal(err) + } + r, _, err := e.Receipt(p.RunID) + if err != nil { + t.Fatal(err) + } + if r.Experiment == nil || *r.Experiment != *experiment { + t.Fatalf("experiment context was not projected: %+v", r.Experiment) + } + if _, err := e.Replay(p.RunID); err != nil { + t.Fatalf("experiment metadata changed replay: %v", err) + } +} + +func TestInitializationFailureHasRunIDJournalAndReceipt(t *testing.T) { + skillDir := t.TempDir() + runsDir := filepath.Join(t.TempDir(), "runs") + if err := os.MkdirAll(runsDir, 0o700); err != nil { + t.Fatal(err) + } + e := &Engine{SkillDir: skillDir, RunsDir: runsDir, SupervisorVersion: "1.0.0", Stderr: os.Stderr} + _, err := e.StartRun(nil) + var runErr *RunError + if !errors.As(err, &runErr) || runErr.RunID == "" { + t.Fatalf("initialization error did not preserve run id: %v", err) + } + l, openErr := e.Log(runErr.RunID) + if openErr != nil { + t.Fatal(openErr) + } + if got := l.Events(); len(got) != 2 || got[0].Type != runlog.RunOpened || got[1].Type != runlog.RunInitializationFailed { + t.Fatalf("unexpected initialization journal: %+v", got) + } + r, _, loadErr := receipt.StoreForRunsDir(e.RunsDir).LoadRun(runErr.RunID) + if loadErr != nil { + t.Fatal(loadErr) + } + if r.Outcome.Phase != "initialization_failed" || r.Outcome.FailureCode != "runner_missing" { + t.Fatalf("unexpected initialization receipt: %+v", r.Outcome) + } +} + +func TestRustRunWithoutLockfileFailsDuringInitialization(t *testing.T) { + skillDir := t.TempDir() + manifest := `{"version":1,"yield_version":"1.0.0","language":"rust","run":["cargo","run"]}` + if err := os.WriteFile(filepath.Join(skillDir, "skill.json"), []byte(manifest), 0o600); err != nil { + t.Fatal(err) + } + runsDir := filepath.Join(t.TempDir(), "runs") + if err := os.MkdirAll(runsDir, 0o700); err != nil { + t.Fatal(err) + } + e := &Engine{SkillDir: skillDir, RunsDir: runsDir, SupervisorVersion: "1.0.0", Stderr: os.Stderr} + _, err := e.StartRun(nil) + var runErr *RunError + if !errors.As(err, &runErr) { + t.Fatalf("expected run-bound initialization error, got %v", err) + } + r, _, loadErr := receipt.StoreForRunsDir(runsDir).LoadRun(runErr.RunID) + if loadErr != nil { + t.Fatal(loadErr) + } + if r.Outcome.FailureCode != "source_lockfile_missing" { + t.Fatalf("failure code = %q", r.Outcome.FailureCode) + } +} + +func TestMaterializationFailureDoesNotRewriteJournal(t *testing.T) { + e := testEngine(t, "skill-basic") + yieldDir := filepath.Dir(e.RunsDir) + if err := os.WriteFile(filepath.Join(yieldDir, "receipts"), []byte("block directory creation"), 0o600); err != nil { + t.Fatal(err) + } + _, err := e.StartRun(nil) + var runErr *RunError + if !errors.As(err, &runErr) { + t.Fatalf("expected run-bound materialization error, got %v", err) + } + l, openErr := e.Log(runErr.RunID) + if openErr != nil { + t.Fatal(openErr) + } + if len(l.Events()) < 3 || l.Events()[0].Type != runlog.RunOpened || l.Events()[1].Type != runlog.RunStarted { + t.Fatalf("formal journal was not preserved: %+v", l.Events()) + } +} + +func TestOutboxStateDoesNotModifyJournalOrReplay(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("true fixture is Unix-only") + } + e := testEngine(t, "skill-basic") + p, err := e.StartRun(nil) + if err != nil { + t.Fatal(err) + } + journalPath := filepath.Join(e.RunsDir, p.RunID+".jsonl") + before, err := os.ReadFile(journalPath) + if err != nil { + t.Fatal(err) + } + r, raw, err := e.Receipt(p.RunID) + if err != nil { + t.Fatal(err) + } + manager := outbox.New(filepath.Dir(e.RunsDir)) + if err := manager.Enqueue("test-sink", r, raw); err != nil { + t.Fatal(err) + } + if _, err := manager.Deliver(context.Background(), "test-sink", []string{"true"}, time.Second); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(journalPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatal("outbox operation modified the authoritative journal") + } + if _, err := e.Replay(p.RunID); err != nil { + t.Fatalf("outbox operation changed replay: %v", err) + } +} + func TestConcurrentIdenticalResumeCommitsOnce(t *testing.T) { e := testEngine(t, "skill-basic") p, err := e.StartRun(nil) @@ -73,6 +223,29 @@ func TestConcurrentIdenticalResumeCommitsOnce(t *testing.T) { } } +func TestConcurrentResumeAndReceiptInspectionSeeWholePrefixes(t *testing.T) { + e := testEngine(t, "skill-basic") + p, err := e.StartRun(nil) + if err != nil { + t.Fatal(err) + } + response, err := json.Marshal(protocol.ResponseEnvelope{ + RunID: p.RunID, Sequence: p.Envelope.Sequence, RequestID: p.Envelope.Request.ID, + Status: "completed", Result: json.RawMessage(`{"value":"preserve"}`), + }) + if err != nil { + t.Fatal(err) + } + errs := make(chan error, 2) + go func() { _, callErr := e.Resume(p.RunID, response, false); errs <- callErr }() + go func() { _, _, callErr := e.Receipt(p.RunID); errs <- callErr }() + for range 2 { + if err := <-errs; err != nil { + t.Fatalf("concurrent transition observed a partial prefix: %v", err) + } + } +} + func TestConcurrentResumeProcessesCommitOnce(t *testing.T) { e := testEngine(t, "skill-basic") p, err := e.StartRun(nil) @@ -318,6 +491,19 @@ func TestEndToEndRunResumeComplete(t *testing.T) { if _, err := respond(t, e, &Progress{RunID: p.RunID, Envelope: &protocol.RequestEnvelope{Sequence: 2, Request: protocol.Request{ID: "summarize"}}}, `{"summary":"again"}`, false); err == nil { t.Fatal("responses on a closed run must be refused") } + receipt, _, err := e.Receipt(p.RunID) + if err != nil { + t.Fatal(err) + } + rejections := map[string]int{} + for _, rejection := range receipt.ResponseRejections { + rejections[rejection.Reason] = rejection.Count + } + for _, reason := range []string{"stale-response", "schema-invalid", "run-closed"} { + if rejections[reason] != 1 { + t.Fatalf("receipt rejection %s count = %d", reason, rejections[reason]) + } + } } func TestReplayIsDeterministic(t *testing.T) { diff --git a/internal/guard/guard.go b/internal/guard/guard.go index 33f921d..3959d21 100644 --- a/internal/guard/guard.go +++ b/internal/guard/guard.go @@ -39,15 +39,17 @@ func reject(reason RejectReason, format string, args ...any) *Rejection { // RunState is the guard-relevant projection of a run log. type RunState struct { - RunID string - BoundDigest string - Skill protocol.SkillRef - Pending *protocol.RequestEnvelope // unanswered operation, if any - Completed map[int]string // sequence -> result digest - CompletedRequest map[int]string // sequence -> request id - Closed bool // a terminal run.* event exists - ReqFailed bool // a requirement.failed event exists - Diverged bool + RunID string + BoundDigest string + Skill protocol.SkillRef + Pending *protocol.RequestEnvelope // unanswered operation, if any + Completed map[int]string // sequence -> result digest + CompletedRequest map[int]string // sequence -> request id + Closed bool // a terminal run.* event exists + ReqFailed bool // a requirement.failed event exists + Diverged bool + SourceDigestProfile string + InitializationFailed bool } // Reconstruct folds a run log into its guard state. The log is the only @@ -56,10 +58,19 @@ func Reconstruct(l *runlog.Log) (*RunState, error) { s := &RunState{Completed: map[int]string{}, CompletedRequest: map[int]string{}} for _, e := range l.Events() { switch e.Type { + case runlog.RunOpened: + var d struct { + RunID string `json:"run_id"` + } + if err := e.Decode(&d); err != nil { + return nil, err + } + s.RunID = d.RunID case runlog.RunStarted: var d struct { - RunID string `json:"run_id"` - Skill protocol.SkillRef `json:"skill"` + RunID string `json:"run_id"` + Skill protocol.SkillRef `json:"skill"` + SourceDigestProfile string `json:"source_digest_profile"` } if err := e.Decode(&d); err != nil { return nil, err @@ -67,6 +78,7 @@ func Reconstruct(l *runlog.Log) (*RunState, error) { s.RunID = d.RunID s.Skill = d.Skill s.BoundDigest = d.Skill.Digest + s.SourceDigestProfile = d.SourceDigestProfile case runlog.OperationRequested: var env protocol.RequestEnvelope if err := e.Decode(&env); err != nil { @@ -95,10 +107,14 @@ func Reconstruct(l *runlog.Log) (*RunState, error) { return nil, err } s.BoundDigest = d.To + s.Skill.Digest = d.To case runlog.RequirementFailed: s.ReqFailed = true case runlog.ReplayDiverged: s.Diverged = true + case runlog.RunInitializationFailed: + s.InitializationFailed = true + s.Closed = true case runlog.RunCompleted, runlog.RunBlocked, runlog.RunRefused: s.Closed = true } diff --git a/internal/outbox/outbox.go b/internal/outbox/outbox.go new file mode 100644 index 0000000..2b8c5ae --- /dev/null +++ b/internal/outbox/outbox.go @@ -0,0 +1,568 @@ +// Package outbox provides independent, resumable delivery of materialized +// receipts. It never reads or writes the authoritative run journal. +package outbox + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hash" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "time" + + "github.com/gofrs/flock" + "github.com/operatorstack/yield/internal/receipt" +) + +type Manager struct { + Root string + Now func() time.Time +} + +type AttemptEvent struct { + Sequence int `json:"sequence"` + At string `json:"at"` + Type string `json:"type"` + Code string `json:"code,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + DiagnosticDigest string `json:"diagnostic_digest,omitempty"` +} + +type EntryStatus struct { + SinkID string `json:"sink_id"` + ReceiptDigest string `json:"receipt_digest"` + State string `json:"state"` + Attempts int `json:"attempts"` + LastCode string `json:"last_code,omitempty"` +} + +func New(yieldDir string) *Manager { + return &Manager{Root: filepath.Join(yieldDir, "outbox"), Now: time.Now} +} + +var sinkIdentifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +func validateSinkID(sinkID string) error { + if len(sinkID) == 0 || len(sinkID) > 64 || !sinkIdentifier.MatchString(sinkID) { + return fmt.Errorf("outbox: sink id must be a portable identifier of at most 64 characters") + } + return nil +} + +// Enqueue atomically adds one canonical receipt to a sink's pending set. +func (m *Manager) Enqueue(sinkID string, r *receipt.RunReceipt, raw []byte) error { + if err := validateSinkID(sinkID); err != nil { + return err + } + if r == nil || r.ReceiptDigest == "" { + return fmt.Errorf("outbox: incomplete receipt") + } + if err := r.Validate(); err != nil { + return err + } + if err := receipt.VerifyCanonical(r, raw); err != nil { + return err + } + path, err := m.pendingPath(sinkID, r.ReceiptDigest) + if err != nil { + return err + } + return writeImmutable(path, raw) +} + +// Deliver sends every selected pending receipt. Different digests are +// independent; each digest is serialized with its own file lock. +func (m *Manager) Deliver(ctx context.Context, sinkID string, argv []string, timeout time.Duration) ([]EntryStatus, error) { + if err := validateSinkID(sinkID); err != nil { + return nil, err + } + if len(argv) == 0 { + return nil, fmt.Errorf("outbox: sink command is empty") + } + if timeout <= 0 { + timeout = 5 * time.Minute + } + digests, err := m.pendingDigests(sinkID) + if err != nil { + return nil, err + } + statuses := make([]EntryStatus, 0, len(digests)) + var deliveryErrors []error + for _, digest := range digests { + status, deliverErr := m.deliverOne(ctx, sinkID, digest, argv, timeout) + statuses = append(statuses, status) + if deliverErr != nil { + deliveryErrors = append(deliveryErrors, deliverErr) + } + } + return statuses, errors.Join(deliveryErrors...) +} + +func (m *Manager) deliverOne(parent context.Context, sinkID, digest string, argv []string, timeout time.Duration) (EntryStatus, error) { + lockPath, err := m.lockPath(sinkID, digest) + if err != nil { + return EntryStatus{}, err + } + if err := secureMkdirAll(filepath.Dir(lockPath)); err != nil { + return EntryStatus{}, err + } + lock := flock.New(lockPath) + if err := lock.Lock(); err != nil { + return EntryStatus{}, err + } + _ = os.Chmod(lockPath, 0o600) + defer func() { _ = lock.Unlock(); _ = lock.Close() }() + + status, err := m.statusOne(sinkID, digest) + if err != nil { + return EntryStatus{}, err + } + if status.State == "accepted" { + if err := m.ensureAcceptedMarker(sinkID, digest); err != nil { + return status, err + } + return status, nil + } + if status.State == "failed" || status.State == "delivery_unknown" { + return status, nil + } + raw, err := os.ReadFile(m.mustPendingPath(sinkID, digest)) + if err != nil { + return status, err + } + if err := verifyReceipt(raw, digest); err != nil { + if _, appendErr := m.appendAttempt(sinkID, digest, AttemptEvent{Type: "delivery_failed", Code: "invalid_pending_receipt"}); appendErr != nil { + return status, errors.Join(err, appendErr) + } + status, _ = m.statusOne(sinkID, digest) + return status, err + } + if _, err := m.appendAttempt(sinkID, digest, AttemptEvent{Type: "delivery_started"}); err != nil { + return status, err + } + + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + cmd.Stdin = bytes.NewReader(raw) + cmd.Env = append(os.Environ(), "YIELD_RECEIPT_DIGEST="+digest, "YIELD_SINK_ID="+sinkID) + stdout := newDigestWriter() + stderr := newDigestWriter() + cmd.Stdout = stdout + cmd.Stderr = stderr + runErr := cmd.Run() + if runErr == nil { + if _, err := m.appendAttempt(sinkID, digest, AttemptEvent{Type: "delivery_accepted"}); err != nil { + status, statusErr := m.statusOne(sinkID, digest) + return status, errors.Join(err, statusErr) + } + if err := m.ensureAcceptedMarker(sinkID, digest); err != nil { + status, statusErr := m.statusOne(sinkID, digest) + return status, errors.Join(err, statusErr) + } + return m.statusOne(sinkID, digest) + } + event := AttemptEvent{Type: "delivery_failed", Code: "process_failed"} + if ctx.Err() == context.DeadlineExceeded { + event.Code = "timeout" + } else { + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + code := exitErr.ExitCode() + event.ExitCode = &code + event.Code = "nonzero_exit" + } + } + if stdout.n > 0 || stderr.n > 0 { + event.DiagnosticDigest = digestBytes(append(stdout.h.Sum(nil), stderr.h.Sum(nil)...)) + } + if _, err := m.appendAttempt(sinkID, digest, event); err != nil { + return status, errors.Join(runErr, err) + } + status, statusErr := m.statusOne(sinkID, digest) + return status, errors.Join(fmt.Errorf("deliver %s: %s", digest, event.Code), statusErr) +} + +// Retry marks a failed or delivery-unknown entry as pending for explicit retry. +func (m *Manager) Retry(sinkID, digest string) error { + if err := validateSinkID(sinkID); err != nil { + return err + } + lockPath, err := m.lockPath(sinkID, digest) + if err != nil { + return err + } + if err := secureMkdirAll(filepath.Dir(lockPath)); err != nil { + return err + } + lock := flock.New(lockPath) + if err := lock.Lock(); err != nil { + return err + } + _ = os.Chmod(lockPath, 0o600) + defer func() { _ = lock.Unlock(); _ = lock.Close() }() + status, err := m.statusOne(sinkID, digest) + if err != nil { + return err + } + if status.State == "accepted" { + return fmt.Errorf("outbox: receipt %s is already accepted", digest) + } + _, err = m.appendAttempt(sinkID, digest, AttemptEvent{Type: "retry_requested"}) + return err +} + +func (m *Manager) Status(sinkFilter string) ([]EntryStatus, error) { + if sinkFilter != "" { + if err := validateSinkID(sinkFilter); err != nil { + return nil, err + } + } + entries, err := os.ReadDir(m.Root) + if errors.Is(err, os.ErrNotExist) { + return []EntryStatus{}, nil + } + if err != nil { + return nil, err + } + var statuses []EntryStatus + for _, entry := range entries { + if !entry.IsDir() || sinkFilter != "" && entry.Name() != sinkFilter { + continue + } + digests, err := m.pendingDigests(entry.Name()) + if err != nil { + return nil, err + } + for _, digest := range digests { + status, err := m.statusOne(entry.Name(), digest) + if err != nil { + return nil, err + } + statuses = append(statuses, status) + } + } + sort.Slice(statuses, func(i, j int) bool { + if statuses[i].SinkID != statuses[j].SinkID { + return statuses[i].SinkID < statuses[j].SinkID + } + return statuses[i].ReceiptDigest < statuses[j].ReceiptDigest + }) + return statuses, nil +} + +func (m *Manager) statusOne(sinkID, digest string) (EntryStatus, error) { + status := EntryStatus{SinkID: sinkID, ReceiptDigest: digest, State: "pending"} + attempts, err := m.readAttempts(sinkID, digest) + if err != nil { + return status, err + } + for _, attempt := range attempts { + if attempt.Type == "delivery_started" { + status.Attempts++ + } + } + if len(attempts) == 0 { + return status, nil + } + last := attempts[len(attempts)-1] + status.LastCode = last.Code + switch last.Type { + case "delivery_accepted": + status.State = "accepted" + case "delivery_started": + status.State = "delivery_unknown" + case "delivery_failed": + status.State = "failed" + case "retry_requested": + status.State = "pending" + default: + return status, fmt.Errorf("outbox: unknown attempt type %q", last.Type) + } + return status, nil +} + +func (m *Manager) appendAttempt(sinkID, digest string, event AttemptEvent) (AttemptEvent, error) { + path, err := m.attemptPath(sinkID, digest) + if err != nil { + return AttemptEvent{}, err + } + if err := secureMkdirAll(filepath.Dir(path)); err != nil { + return AttemptEvent{}, err + } + attempts, err := m.readAttempts(sinkID, digest) + if err != nil { + return AttemptEvent{}, err + } + event.Sequence = len(attempts) + 1 + event.At = m.Now().UTC().Format(time.RFC3339Nano) + raw, err := json.Marshal(event) + if err != nil { + return AttemptEvent{}, err + } + if err := repairTrailingPartial(path); err != nil { + return AttemptEvent{}, err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return AttemptEvent{}, err + } + if _, err := f.Write(append(raw, '\n')); err != nil { + f.Close() + return AttemptEvent{}, err + } + if err := f.Sync(); err != nil { + f.Close() + return AttemptEvent{}, err + } + return event, f.Close() +} + +func (m *Manager) readAttempts(sinkID, digest string) ([]AttemptEvent, error) { + path, err := m.attemptPath(sinkID, digest) + if err != nil { + return nil, err + } + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return []AttemptEvent{}, nil + } + if err != nil { + return nil, err + } + var attempts []AttemptEvent + lines := bytes.Split(raw, []byte{'\n'}) + for index, line := range lines { + if len(line) == 0 { + continue + } + if index == len(lines)-1 && raw[len(raw)-1] != '\n' { + break + } + var event AttemptEvent + if err := json.Unmarshal(line, &event); err != nil { + return nil, fmt.Errorf("outbox: corrupt attempt journal: %w", err) + } + if event.Sequence != len(attempts)+1 { + return nil, fmt.Errorf("outbox: attempt sequence is not monotone") + } + if err := validateAttempt(event); err != nil { + return nil, err + } + attempts = append(attempts, event) + } + return attempts, nil +} + +func validateAttempt(event AttemptEvent) error { + if event.Sequence < 1 { + return fmt.Errorf("outbox: invalid attempt sequence") + } + if _, err := time.Parse(time.RFC3339Nano, event.At); err != nil { + return fmt.Errorf("outbox: invalid attempt timestamp") + } + switch event.Type { + case "delivery_started", "delivery_accepted", "retry_requested": + if event.Code != "" || event.ExitCode != nil || event.DiagnosticDigest != "" { + return fmt.Errorf("outbox: unexpected attempt diagnostics") + } + case "delivery_failed": + validCode := event.Code == "process_failed" || event.Code == "timeout" || event.Code == "nonzero_exit" || event.Code == "invalid_pending_receipt" + if !validCode { + return fmt.Errorf("outbox: invalid delivery failure code") + } + if event.DiagnosticDigest != "" { + if _, err := digestName(event.DiagnosticDigest); err != nil { + return fmt.Errorf("outbox: invalid diagnostic digest") + } + } + default: + return fmt.Errorf("outbox: unknown attempt type %q", event.Type) + } + return nil +} + +func repairTrailingPartial(path string) error { + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if len(raw) == 0 || raw[len(raw)-1] == '\n' { + return nil + } + lastNewline := bytes.LastIndexByte(raw, '\n') + return os.Truncate(path, int64(lastNewline+1)) +} + +func (m *Manager) pendingDigests(sinkID string) ([]string, error) { + dir := filepath.Join(m.Root, sinkID, "pending") + entries, err := os.ReadDir(dir) + if errors.Is(err, os.ErrNotExist) { + return []string{}, nil + } + if err != nil { + return nil, err + } + var digests []string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + hexDigest := strings.TrimSuffix(entry.Name(), ".json") + if len(hexDigest) == 64 { + digests = append(digests, "sha256:"+hexDigest) + } + } + sort.Strings(digests) + return digests, nil +} + +func (m *Manager) ensureAcceptedMarker(sinkID, digest string) error { + path, err := m.acceptedPath(sinkID, digest) + if err != nil { + return err + } + raw, _ := json.Marshal(map[string]string{"receipt_digest": digest}) + return writeImmutable(path, raw) +} + +func (m *Manager) pendingPath(sinkID, digest string) (string, error) { + name, err := digestName(digest) + return filepath.Join(m.Root, sinkID, "pending", name+".json"), err +} +func (m *Manager) mustPendingPath(sinkID, digest string) string { + path, _ := m.pendingPath(sinkID, digest) + return path +} +func (m *Manager) attemptPath(sinkID, digest string) (string, error) { + name, err := digestName(digest) + return filepath.Join(m.Root, sinkID, "attempts", name+".jsonl"), err +} +func (m *Manager) acceptedPath(sinkID, digest string) (string, error) { + name, err := digestName(digest) + return filepath.Join(m.Root, sinkID, "accepted", name+".json"), err +} +func (m *Manager) lockPath(sinkID, digest string) (string, error) { + name, err := digestName(digest) + return filepath.Join(m.Root, sinkID, "locks", name+".lock"), err +} + +func digestName(digest string) (string, error) { + value := strings.TrimPrefix(digest, "sha256:") + if len(value) != 64 { + return "", fmt.Errorf("outbox: invalid receipt digest") + } + if _, err := hex.DecodeString(value); err != nil { + return "", fmt.Errorf("outbox: invalid receipt digest") + } + return value, nil +} + +func writeImmutable(path string, content []byte) error { + if err := secureMkdirAll(filepath.Dir(path)); err != nil { + return err + } + if existing, err := os.ReadFile(path); err == nil { + if !bytes.Equal(existing, content) { + return fmt.Errorf("outbox: content collision at %s", path) + } + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".outbox-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(content); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Link(tmpPath, path); err != nil { + if existing, readErr := os.ReadFile(path); readErr == nil && bytes.Equal(existing, content) { + return nil + } + return err + } + return syncDir(filepath.Dir(path)) +} + +func secureMkdirAll(path string) error { + if err := os.MkdirAll(path, 0o700); err != nil { + return err + } + return os.Chmod(path, 0o700) +} + +func syncDir(path string) error { + if runtime.GOOS == "windows" { + return nil + } + dir, err := os.Open(path) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} + +func digestBytes(raw []byte) string { + digest := sha256.Sum256(raw) + return "sha256:" + hex.EncodeToString(digest[:]) +} + +func verifyReceipt(raw []byte, digest string) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var r receipt.RunReceipt + if err := decoder.Decode(&r); err != nil { + return fmt.Errorf("outbox: pending receipt does not decode: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return fmt.Errorf("outbox: pending receipt has trailing content") + } + if r.ReceiptDigest != digest { + return fmt.Errorf("outbox: pending receipt digest does not match its name") + } + return receipt.VerifyCanonical(&r, raw) +} + +type digestWriter struct { + h hash.Hash + n int64 +} + +func newDigestWriter() *digestWriter { return &digestWriter{h: sha256.New()} } + +func (writer *digestWriter) Write(value []byte) (int, error) { + written, err := writer.h.Write(value) + writer.n += int64(written) + return written, err +} diff --git a/internal/outbox/outbox_test.go b/internal/outbox/outbox_test.go new file mode 100644 index 0000000..17592b9 --- /dev/null +++ b/internal/outbox/outbox_test.go @@ -0,0 +1,186 @@ +package outbox + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/operatorstack/yield/internal/receipt" +) + +func testReceipt(t *testing.T) (*receipt.RunReceipt, []byte) { + t.Helper() + r := &receipt.RunReceipt{ + Schema: receipt.Schema, Kind: receipt.Kind, + Journal: receipt.JournalBinding{RunID: "run_1", HeadSequence: 1, HeadDigest: digestBytes([]byte("journal"))}, + Run: receipt.RunIdentity{ID: "run_1"}, + Skill: receipt.SkillIdentity{Name: "test", BindingDigest: digestBytes([]byte("skill"))}, + Timing: receipt.TimingSummary{StartedAt: "2026-08-20T10:00:00Z", LastObservedAt: "2026-08-20T10:00:00Z"}, + Operations: []receipt.OperationObservation{}, OperationSummaries: []receipt.OperationSummary{}, + Outcome: receipt.OutcomeSummary{Phase: "advancing"}, Requirements: []receipt.RequirementOutcome{}, + ResponseRejections: []receipt.ResponseRejectionSummary{}, Divergences: []receipt.DivergenceOutcome{}, + } + if err := receipt.Seal(r); err != nil { + t.Fatal(err) + } + raw, err := receipt.CanonicalBytes(r) + if err != nil { + t.Fatal(err) + } + return r, raw +} + +func TestEnqueueIsByteIdempotent(t *testing.T) { + m := New(t.TempDir()) + r, raw := testReceipt(t) + if err := m.Enqueue("test-sink", r, raw); err != nil { + t.Fatal(err) + } + if err := m.Enqueue("test-sink", r, raw); err != nil { + t.Fatal(err) + } + statuses, err := m.Status("test-sink") + if err != nil || len(statuses) != 1 || statuses[0].State != "pending" { + t.Fatalf("unexpected status: %+v, %v", statuses, err) + } +} + +func TestDeliveryFailureIsRetryableAndDoesNotPersistOutput(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + m := New(t.TempDir()) + r, raw := testReceipt(t) + if err := m.Enqueue("test-sink", r, raw); err != nil { + t.Fatal(err) + } + _, err := m.Deliver(context.Background(), "test-sink", []string{"sh", "-c", "echo SECRET_SINK_OUTPUT >&2; exit 7"}, time.Second) + if err == nil { + t.Fatal("failed sink was accepted") + } + statuses, err := m.Status("test-sink") + if err != nil || statuses[0].State != "failed" { + t.Fatalf("unexpected status: %+v, %v", statuses, err) + } + attemptPath, _ := m.attemptPath("test-sink", r.ReceiptDigest) + attempts, err := os.ReadFile(attemptPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(attempts), "SECRET_SINK_OUTPUT") || strings.Contains(string(attempts), "echo") { + t.Fatalf("attempt journal leaked output or argv: %s", attempts) + } + if err := m.Retry("test-sink", r.ReceiptDigest); err != nil { + t.Fatal(err) + } + statuses, _ = m.Status("test-sink") + if statuses[0].State != "pending" { + t.Fatalf("retry state = %s", statuses[0].State) + } +} + +func TestDeliveryUnknownAndAcceptedRecovery(t *testing.T) { + m := New(t.TempDir()) + m.Now = func() time.Time { return time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) } + r, raw := testReceipt(t) + if err := m.Enqueue("test-sink", r, raw); err != nil { + t.Fatal(err) + } + if _, err := m.appendAttempt("test-sink", r.ReceiptDigest, AttemptEvent{Type: "delivery_started"}); err != nil { + t.Fatal(err) + } + statuses, _ := m.Status("test-sink") + if statuses[0].State != "delivery_unknown" { + t.Fatalf("status = %s", statuses[0].State) + } + if err := m.Retry("test-sink", r.ReceiptDigest); err != nil { + t.Fatal(err) + } + if _, err := m.appendAttempt("test-sink", r.ReceiptDigest, AttemptEvent{Type: "delivery_accepted"}); err != nil { + t.Fatal(err) + } + if _, err := m.Deliver(context.Background(), "test-sink", []string{"unused"}, time.Second); err != nil { + t.Fatal(err) + } + acceptedPath, _ := m.acceptedPath("test-sink", r.ReceiptDigest) + marker, err := os.ReadFile(acceptedPath) + if err != nil { + t.Fatal(err) + } + var accepted map[string]string + if err := json.Unmarshal(marker, &accepted); err != nil || accepted["receipt_digest"] != r.ReceiptDigest { + t.Fatalf("invalid accepted marker: %s", marker) + } + if _, err := os.Stat(filepath.Join(m.Root, "test-sink", "pending")); err != nil { + t.Fatal(err) + } +} + +func TestConcurrentExportersDeliverOneAttemptPerDigest(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + m := New(t.TempDir()) + r, raw := testReceipt(t) + if err := m.Enqueue("test-sink", r, raw); err != nil { + t.Fatal(err) + } + counter := filepath.Join(t.TempDir(), "counter") + argv := []string{"sh", "-c", `cat >/dev/null; echo delivered >> "$1"`, "sink", counter} + var wg sync.WaitGroup + errs := make(chan error, 2) + for range 2 { + wg.Add(1) + go func() { + defer wg.Done() + _, err := m.Deliver(context.Background(), "test-sink", argv, time.Second) + errs <- err + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + content, err := os.ReadFile(counter) + if err != nil { + t.Fatal(err) + } + if strings.Count(string(content), "delivered") != 1 { + t.Fatalf("sink deliveries = %q", content) + } + statuses, _ := m.Status("test-sink") + if statuses[0].State != "accepted" || statuses[0].Attempts != 1 { + t.Fatalf("unexpected concurrent status: %+v", statuses[0]) + } +} + +func TestPartialAttemptWriteIsRepairable(t *testing.T) { + m := New(t.TempDir()) + r, raw := testReceipt(t) + if err := m.Enqueue("test-sink", r, raw); err != nil { + t.Fatal(err) + } + path, _ := m.attemptPath("test-sink", r.ReceiptDigest) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{"sequence":1,"at":"2026`), 0o600); err != nil { + t.Fatal(err) + } + if err := m.Retry("test-sink", r.ReceiptDigest); err != nil { + t.Fatal(err) + } + attempts, err := m.readAttempts("test-sink", r.ReceiptDigest) + if err != nil || len(attempts) != 1 || attempts[0].Type != "retry_requested" { + t.Fatalf("partial attempt was not repaired: %+v, %v", attempts, err) + } +} diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go index cecfc32..e8b6ea5 100644 --- a/internal/protocol/protocol.go +++ b/internal/protocol/protocol.go @@ -23,6 +23,10 @@ import ( // Version is the protocol identifier carried by every request envelope. const Version = "yield.v1" +// SkillSourceProfileV1 identifies the complete, language-neutral source +// selection used by new runs. DigestSkillDir remains the legacy replay profile. +const SkillSourceProfileV1 = "yield.skill-source.v1" + // OpKind is the closed set of operations a skill program may yield. type OpKind string @@ -330,6 +334,31 @@ func compactJSON(raw json.RawMessage) []byte { // DigestSkillDir computes the skill source digest: sha256 over the sorted // relative paths and contents of *.go, SKILL.md, and go.mod files. func DigestSkillDir(dir string) (string, error) { + return digestSkillDir(dir, legacySkillSource, false) +} + +// DigestSkillDirProfile computes a source digest under a named, versioned +// selection profile. +func DigestSkillDirProfile(dir, profile string) (string, error) { + if profile != SkillSourceProfileV1 { + return "", fmt.Errorf("unknown skill source digest profile %q", profile) + } + language := "go" + if raw, err := os.ReadFile(filepath.Join(dir, "skill.json")); err == nil { + var manifest struct { + Language string `json:"language"` + } + if err := json.Unmarshal(raw, &manifest); err != nil { + return "", fmt.Errorf("read source profile language: %w", err) + } + if manifest.Language != "" { + language = manifest.Language + } + } + return digestSkillDir(dir, completeSkillSourceV1(language), true) +} + +func digestSkillDir(dir string, include func(string) bool, skipGenerated bool) (string, error) { var files []string err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { @@ -337,19 +366,12 @@ func DigestSkillDir(dir string) (string, error) { } if d.IsDir() { name := d.Name() - if name == ".yield" || name == "fixtures" || strings.HasPrefix(name, ".") && path != dir { + if name == ".yield" || name == "fixtures" || skipGenerated && generatedSourceDir(name) || strings.HasPrefix(name, ".") && path != dir { return filepath.SkipDir } return nil } - base := d.Name() - switch { - case strings.HasSuffix(base, ".go"), strings.HasSuffix(base, ".ts"), - strings.HasSuffix(base, ".js"), strings.HasSuffix(base, ".mjs"), - strings.HasSuffix(base, ".py"): - files = append(files, path) - case base == "SKILL.md", base == "go.mod", base == "skill.json", - base == "package.json", base == "pyproject.toml", base == "requirements.txt": + if include(d.Name()) { files = append(files, path) } return nil @@ -374,6 +396,60 @@ func DigestSkillDir(dir string) (string, error) { return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil } +func generatedSourceDir(name string) bool { + switch name { + case "target", "node_modules", "dist", "build", "__pycache__", ".venv", "venv": + return true + } + return false +} + +func legacySkillSource(base string) bool { + for _, extension := range []string{".go", ".ts", ".js", ".mjs", ".py"} { + if strings.HasSuffix(base, extension) { + return true + } + } + switch base { + case "SKILL.md", "go.mod", "skill.json", "package.json", "pyproject.toml", "requirements.txt": + return true + } + return false +} + +func completeSkillSourceV1(language string) func(string) bool { + return func(base string) bool { + if base == "SKILL.md" || base == "skill.json" { + return true + } + switch language { + case "typescript": + for _, extension := range []string{".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"} { + if strings.HasSuffix(base, extension) { + return true + } + } + switch base { + case "package.json", "package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "deno.json", "deno.lock": + return true + } + case "python": + if strings.HasSuffix(base, ".py") || strings.HasPrefix(base, "requirements") && strings.HasSuffix(base, ".txt") { + return true + } + switch base { + case "pyproject.toml", "uv.lock", "poetry.lock", "Pipfile", "Pipfile.lock": + return true + } + case "rust": + return strings.HasSuffix(base, ".rs") || base == "Cargo.toml" || base == "Cargo.lock" + default: + return strings.HasSuffix(base, ".go") || base == "go.mod" || base == "go.sum" || base == "go.work" || base == "go.work.sum" + } + return false + } +} + // ValidateResult checks a completed result against the request's embedded // JSON schema. A nil schema accepts any JSON value. func ValidateResult(schema, result json.RawMessage) error { diff --git a/internal/protocol/protocol_test.go b/internal/protocol/protocol_test.go index fdb5cbb..1dbcdc9 100644 --- a/internal/protocol/protocol_test.go +++ b/internal/protocol/protocol_test.go @@ -80,6 +80,50 @@ func TestDigestSkillDirIsContentBound(t *testing.T) { } } +func TestSkillSourceProfileV1CoversRustAndLockfiles(t *testing.T) { + dir := t.TempDir() + write := func(name, content string) { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + write("main.rs", "fn main() {}") + write("Cargo.lock", "version = 3") + write("skill.json", `{"version":1,"yield_version":"1.0.0","language":"rust","run":["cargo","run"]}`) + profiled, err := DigestSkillDirProfile(dir, SkillSourceProfileV1) + if err != nil { + t.Fatal(err) + } + legacy, err := DigestSkillDir(dir) + if err != nil { + t.Fatal(err) + } + if profiled == legacy { + t.Fatal("profiled digest must include sources omitted by the legacy profile") + } + write("Cargo.lock", "version = 4") + changed, err := DigestSkillDirProfile(dir, SkillSourceProfileV1) + if err != nil { + t.Fatal(err) + } + if changed == profiled { + t.Fatal("lockfile change must change the profiled digest") + } + if err := os.MkdirAll(filepath.Join(dir, "target", "generated"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "target", "generated", "build.rs"), []byte("generated"), 0o644); err != nil { + t.Fatal(err) + } + stable, err := DigestSkillDirProfile(dir, SkillSourceProfileV1) + if err != nil { + t.Fatal(err) + } + if stable != changed { + t.Fatal("generated build tree changed the source digest") + } +} + func TestRequestDigestIsCompactionInvariant(t *testing.T) { pretty := Request{ID: "a", Kind: OpAgentTask, Payload: json.RawMessage("{\n \"q\": 1\n}"), diff --git a/internal/receipt/ir_test.go b/internal/receipt/ir_test.go new file mode 100644 index 0000000..8aea732 --- /dev/null +++ b/internal/receipt/ir_test.go @@ -0,0 +1,73 @@ +package receipt + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/operatorstack/yield/internal/protocol" + "github.com/operatorstack/yield/internal/runlog" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +func observationSchema(t *testing.T) *jsonschema.Schema { + t.Helper() + path := filepath.Join("..", "..", "ir", "yield.observation.v1", "run-receipt.schema.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + document, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("run-receipt.schema.json", document); err != nil { + t.Fatal(err) + } + schema, err := compiler.Compile("run-receipt.schema.json") + if err != nil { + t.Fatal(err) + } + return schema +} + +func TestGoReceiptValidatesAgainstObservationIR(t *testing.T) { + t0 := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) + digest := protocol.DigestBytes([]byte("source")) + skill := protocol.SkillRef{Name: "schema-test", Version: "1.0.0", Digest: digest} + events := []runlog.Event{ + event(t, 1, runlog.RunOpened, t0, map[string]any{"run_id": "run_schema", "skill_name": "schema-test", "input_digest": digest, "supervisor_version": "1.0.0"}), + event(t, 2, runlog.RunStarted, t0, map[string]any{"run_id": "run_schema", "skill": skill, "input_digest": digest, "supervisor_version": "1.0.0", "required_yield_version": "1.0.0", "source_digest_profile": protocol.SkillSourceProfileV1, "source_digest": digest}), + event(t, 3, runlog.RunCompleted, t0.Add(time.Second), map[string]any{"result": json.RawMessage(`{"ok":true}`)}), + } + r, err := Project(Snapshot{Events: events, Bytes: journalBytes(t, events)}) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + document, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + if err := observationSchema(t).Validate(document); err != nil { + t.Fatalf("Go receipt does not validate against observation IR:\n%s\n%v", raw, err) + } +} + +func TestObservationIRRejectsUnknownFields(t *testing.T) { + raw := []byte(`{"schema":"yield.observation.v1","kind":"run_receipt","receipt_digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","journal":{"run_id":"r","head_sequence":1,"head_digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000"},"run":{"id":"r"},"skill":{"name":"s","binding_digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000"},"timing":{"started_at":"2026-08-20T10:00:00Z","last_observed_at":"2026-08-20T10:00:00Z"},"operations":[],"operation_summaries":[],"outcome":{"phase":"advancing"},"requirements":[],"response_rejections":[],"divergences":[],"prompt":"secret"}`) + document, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + if err := observationSchema(t).Validate(document); err == nil { + t.Fatal("observation IR accepted an unknown raw-content field") + } +} diff --git a/internal/receipt/receipt.go b/internal/receipt/receipt.go new file mode 100644 index 0000000..dad1595 --- /dev/null +++ b/internal/receipt/receipt.go @@ -0,0 +1,878 @@ +// Package receipt projects privacy-safe, portable observations from Yield's +// authoritative append-only run journal. +package receipt + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + "time" + "unicode/utf16" + "unicode/utf8" + + "github.com/operatorstack/yield/internal/protocol" + "github.com/operatorstack/yield/internal/runlog" +) + +const ( + Schema = "yield.observation.v1" + Kind = "run_receipt" +) + +type RunReceipt struct { + Schema string `json:"schema"` + Kind string `json:"kind"` + ReceiptDigest string `json:"receipt_digest,omitempty"` + Journal JournalBinding `json:"journal"` + Run RunIdentity `json:"run"` + Skill SkillIdentity `json:"skill"` + Runtime *RuntimeIdentity `json:"runtime,omitempty"` + Timing TimingSummary `json:"timing"` + Operations []OperationObservation `json:"operations"` + OperationSummaries []OperationSummary `json:"operation_summaries"` + Outcome OutcomeSummary `json:"outcome"` + Requirements []RequirementOutcome `json:"requirements"` + ResponseRejections []ResponseRejectionSummary `json:"response_rejections"` + Divergences []DivergenceOutcome `json:"divergences"` + Experiment *ExperimentContext `json:"experiment,omitempty"` +} + +type JournalBinding struct { + RunID string `json:"run_id"` + HeadSequence int `json:"head_sequence"` + HeadDigest string `json:"head_digest"` +} + +type RunIdentity struct { + ID string `json:"id"` + InputDigest string `json:"input_digest,omitempty"` +} + +type SkillIdentity struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + BindingDigest string `json:"binding_digest,omitempty"` + SourceDigest *ProfileDigest `json:"source_digest,omitempty"` +} + +type ProfileDigest struct { + Profile string `json:"profile"` + Value string `json:"value"` +} + +type RuntimeIdentity struct { + SupervisorVersion string `json:"supervisor_version,omitempty"` + RequiredVersion string `json:"required_version,omitempty"` + Compatible *bool `json:"compatible,omitempty"` +} + +type TimingSummary struct { + StartedAt string `json:"started_at"` + LastObservedAt string `json:"last_observed_at"` + EndedAt string `json:"ended_at,omitempty"` + ElapsedMS *int64 `json:"elapsed_ms,omitempty"` + ClockAnomaly bool `json:"clock_anomaly,omitempty"` +} + +type OperationObservation struct { + Sequence int `json:"sequence"` + Kind protocol.OpKind `json:"kind"` + OperationKeyDigest string `json:"operation_key_digest"` + RequestedAt string `json:"requested_at"` + CompletedAt string `json:"completed_at,omitempty"` + ElapsedMS *int64 `json:"elapsed_ms,omitempty"` + ResultDigest string `json:"result_digest,omitempty"` + ClockAnomaly bool `json:"clock_anomaly,omitempty"` +} + +type OperationSummary struct { + Kind protocol.OpKind `json:"kind"` + Requested int `json:"requested"` + Completed int `json:"completed"` + TotalElapsedMS int64 `json:"total_elapsed_ms"` +} + +type OutcomeSummary struct { + Phase string `json:"phase"` + TerminalDisposition string `json:"terminal_disposition,omitempty"` + TerminalCause string `json:"terminal_cause,omitempty"` + ResultDigest string `json:"result_digest,omitempty"` + FailureCode string `json:"failure_code,omitempty"` +} + +type RequirementOutcome struct { + Outcome string `json:"outcome"` + ClaimDigest string `json:"claim_digest"` + EvidenceDigest string `json:"evidence_digest,omitempty"` +} + +type ResponseRejectionSummary struct { + Reason string `json:"reason"` + Count int `json:"count"` +} + +type DivergenceOutcome struct { + Sequence int `json:"sequence"` + Expected string `json:"expected_digest"` + Got string `json:"got_digest"` +} + +type ExperimentContext struct { + ExperimentID string `json:"experiment_id"` + CohortID string `json:"cohort_id,omitempty"` + VariantID string `json:"variant_id"` + Role string `json:"role"` + BaselineVariantID string `json:"baseline_variant_id,omitempty"` + ParentSkillVersion string `json:"parent_skill_version,omitempty"` +} + +// Snapshot is the complete, immutable input to one projection. +type Snapshot struct { + Bytes []byte + Events []runlog.Event +} + +type runOpenedData struct { + RunID string `json:"run_id"` + SkillName string `json:"skill_name"` + InputDigest string `json:"input_digest"` + SupervisorVersion string `json:"supervisor_version"` + Experiment *ExperimentContext `json:"experiment,omitempty"` +} + +type runStartedData struct { + RunID string `json:"run_id"` + Skill protocol.SkillRef `json:"skill"` + InputDigest string `json:"input_digest"` + SupervisorVersion string `json:"supervisor_version"` + RequiredVersion string `json:"required_yield_version"` + SourceDigestProfile string `json:"source_digest_profile"` + SourceDigest string `json:"source_digest"` + Experiment *ExperimentContext `json:"experiment,omitempty"` +} + +type operationCompletedData struct { + Sequence int `json:"sequence"` + RequestID string `json:"request_id"` + Result json.RawMessage `json:"result"` + ResultDigest string `json:"result_digest"` +} + +// Project deterministically derives one receipt from one exact journal prefix. +func Project(snapshot Snapshot) (*RunReceipt, error) { + if len(snapshot.Events) == 0 { + return nil, fmt.Errorf("receipt: journal is empty") + } + if len(snapshot.Bytes) == 0 { + return nil, fmt.Errorf("receipt: journal bytes are empty") + } + + r := &RunReceipt{ + Schema: Schema, + Kind: Kind, + Operations: []OperationObservation{}, + OperationSummaries: []OperationSummary{}, + Requirements: []RequirementOutcome{}, + ResponseRejections: []ResponseRejectionSummary{}, + Divergences: []DivergenceOutcome{}, + } + r.Journal.HeadDigest = digest("", snapshot.Bytes) + r.Journal.HeadSequence = len(snapshot.Events) + + operations := map[int]*OperationObservation{} + requestIDs := map[int]string{} + rejections := map[string]int{} + var started, last, ended time.Time + var previous time.Time + var terminal bool + var openedSeen, startedSeen bool + var initializationFailed, recoverableFailed bool + var initializationCode, executionCode string + var requirementFailed bool + var lastDivergenceSeq int + + for index, event := range snapshot.Events { + if event.Seq != index+1 { + return nil, fmt.Errorf("receipt: event sequence %d, want %d", event.Seq, index+1) + } + if event.At.IsZero() { + return nil, fmt.Errorf("receipt: event %d has no timestamp", event.Seq) + } + if !previous.IsZero() && event.At.UTC().Before(previous) { + r.Timing.ClockAnomaly = true + } + previous = event.At.UTC() + last = event.At.UTC() + if terminal && event.Type != runlog.ResponseRejected { + return nil, fmt.Errorf("receipt: event %d follows a terminal event", event.Seq) + } + + switch event.Type { + case runlog.RunOpened: + if openedSeen || startedSeen || index != 0 { + return nil, fmt.Errorf("receipt: run.opened must be the first event") + } + openedSeen = true + var data runOpenedData + if err := event.Decode(&data); err != nil { + return nil, err + } + r.Run.ID = data.RunID + r.Run.InputDigest = data.InputDigest + r.Skill.Name = data.SkillName + r.Runtime = runtimeIdentity(data.SupervisorVersion, "") + r.Experiment = data.Experiment + started = event.At.UTC() + + case runlog.RunStarted: + if startedSeen { + return nil, fmt.Errorf("receipt: duplicate run.started event") + } + startedSeen = true + var data runStartedData + if err := event.Decode(&data); err != nil { + return nil, err + } + if r.Run.ID != "" && r.Run.ID != data.RunID { + return nil, fmt.Errorf("receipt: run identity changed from %s to %s", r.Run.ID, data.RunID) + } + if r.Skill.Name != "" && r.Skill.Name != data.Skill.Name { + return nil, fmt.Errorf("receipt: skill identity changed from %s to %s", r.Skill.Name, data.Skill.Name) + } + if r.Run.InputDigest != "" && data.InputDigest != "" && r.Run.InputDigest != data.InputDigest { + return nil, fmt.Errorf("receipt: input digest changed at run start") + } + r.Run.ID = data.RunID + r.Run.InputDigest = data.InputDigest + r.Skill.Name = data.Skill.Name + r.Skill.Version = data.Skill.Version + r.Skill.BindingDigest = data.Skill.Digest + if data.SourceDigestProfile != "" && data.SourceDigest != "" { + r.Skill.SourceDigest = &ProfileDigest{Profile: data.SourceDigestProfile, Value: data.SourceDigest} + } + r.Runtime = runtimeIdentity(data.SupervisorVersion, data.RequiredVersion) + if data.Experiment != nil { + r.Experiment = data.Experiment + } + if started.IsZero() { + started = event.At.UTC() + } + + case runlog.DigestMigrated: + if !startedSeen { + return nil, fmt.Errorf("receipt: digest migration precedes run start") + } + var data struct { + To string `json:"to"` + } + if err := event.Decode(&data); err != nil { + return nil, err + } + r.Skill.BindingDigest = data.To + if r.Skill.SourceDigest != nil { + r.Skill.SourceDigest.Value = data.To + } + + case runlog.OperationRequested: + if !startedSeen { + return nil, fmt.Errorf("receipt: operation precedes run start") + } + var envelope protocol.RequestEnvelope + if err := event.Decode(&envelope); err != nil { + return nil, err + } + if envelope.RunID != r.Run.ID { + return nil, fmt.Errorf("receipt: operation sequence %d has a different run id", envelope.Sequence) + } + if envelope.Skill.Digest != r.Skill.BindingDigest { + return nil, fmt.Errorf("receipt: operation sequence %d has a different skill binding", envelope.Sequence) + } + if operations[envelope.Sequence] != nil { + return nil, fmt.Errorf("receipt: duplicate operation sequence %d", envelope.Sequence) + } + operation := &OperationObservation{ + Sequence: envelope.Sequence, + Kind: envelope.Request.Kind, + OperationKeyDigest: digest("yield.operation.v1", []byte(string(envelope.Request.Kind)+"\x00"+envelope.Request.ID)), + RequestedAt: formatTime(event.At), + } + operations[envelope.Sequence] = operation + requestIDs[envelope.Sequence] = envelope.Request.ID + recoverableFailed = false + + case runlog.OperationCompleted: + var data operationCompletedData + if err := event.Decode(&data); err != nil { + return nil, err + } + operation := operations[data.Sequence] + if operation == nil { + return nil, fmt.Errorf("receipt: completion at sequence %d has no request", data.Sequence) + } + if operation.CompletedAt != "" { + return nil, fmt.Errorf("receipt: duplicate completion at sequence %d", data.Sequence) + } + if requestIDs[data.Sequence] != data.RequestID { + return nil, fmt.Errorf("receipt: completion at sequence %d has a different request id", data.Sequence) + } + operation.CompletedAt = formatTime(event.At) + operation.ResultDigest = data.ResultDigest + if operation.ResultDigest == "" && len(data.Result) > 0 { + operation.ResultDigest = protocol.DigestBytes(data.Result) + } + requestedAt, _ := time.Parse(time.RFC3339Nano, operation.RequestedAt) + setDuration(&operation.ElapsedMS, &operation.ClockAnomaly, requestedAt, event.At.UTC()) + recoverableFailed = false + + case runlog.ResponseRejected: + if !startedSeen { + return nil, fmt.Errorf("receipt: response rejection precedes run start") + } + var data struct { + Reason string `json:"reason"` + } + if err := event.Decode(&data); err != nil { + return nil, err + } + if data.Reason != "" { + rejections[data.Reason]++ + } + + case runlog.RequirementPassed, runlog.RequirementFailed: + if !startedSeen { + return nil, fmt.Errorf("receipt: requirement precedes run start") + } + var data protocol.Requirement + if err := event.Decode(&data); err != nil { + return nil, err + } + outcome := "passed" + if event.Type == runlog.RequirementFailed { + outcome = "failed" + requirementFailed = true + } + r.Requirements = append(r.Requirements, RequirementOutcome{ + Outcome: outcome, ClaimDigest: digest("yield.requirement.claim.v1", []byte(data.Claim)), EvidenceDigest: data.EvidenceDigest, + }) + + case runlog.ReplayDiverged: + if !startedSeen { + return nil, fmt.Errorf("receipt: divergence precedes run start") + } + var data protocol.Divergence + if err := event.Decode(&data); err != nil { + return nil, err + } + r.Divergences = append(r.Divergences, DivergenceOutcome{Sequence: data.Sequence, Expected: data.Expected, Got: data.Got}) + lastDivergenceSeq = event.Seq + + case runlog.ExecutionFailed: + if !startedSeen { + return nil, fmt.Errorf("receipt: execution failure precedes run start") + } + var data struct { + Code string `json:"code"` + } + if err := event.Decode(&data); err != nil { + return nil, err + } + recoverableFailed = true + executionCode = data.Code + + case runlog.RunInitializationFailed: + if startedSeen || !openedSeen { + return nil, fmt.Errorf("receipt: initialization failure has invalid ordering") + } + var data struct { + Code string `json:"code"` + } + if err := event.Decode(&data); err != nil { + return nil, err + } + initializationFailed = true + initializationCode = data.Code + terminal = true + ended = event.At.UTC() + + case runlog.RunCompleted: + if !startedSeen { + return nil, fmt.Errorf("receipt: completion precedes run start") + } + var data struct { + Result json.RawMessage `json:"result"` + } + if err := event.Decode(&data); err != nil { + return nil, err + } + terminal = true + ended = event.At.UTC() + r.Outcome.TerminalDisposition = "completed" + r.Outcome.TerminalCause = "completed" + if len(data.Result) > 0 { + r.Outcome.ResultDigest = protocol.DigestBytes(data.Result) + } + + case runlog.RunBlocked: + if !startedSeen { + return nil, fmt.Errorf("receipt: blocked terminal precedes run start") + } + var data struct { + Cause string `json:"cause"` + Reason string `json:"reason"` + } + if err := event.Decode(&data); err != nil { + return nil, err + } + terminal = true + ended = event.At.UTC() + r.Outcome.TerminalDisposition = "blocked" + r.Outcome.TerminalCause = data.Cause + if r.Outcome.TerminalCause == "" { + switch { + case requirementFailed: + r.Outcome.TerminalCause = "requirement_failed" + case strings.Contains(data.Reason, "completion-unproven"): + r.Outcome.TerminalCause = "completion_unproven" + default: + r.Outcome.TerminalCause = "blocked" + } + } + + case runlog.RunRefused: + if !startedSeen { + return nil, fmt.Errorf("receipt: refusal precedes run start") + } + terminal = true + ended = event.At.UTC() + r.Outcome.TerminalDisposition = "refused" + r.Outcome.TerminalCause = "refused" + + default: + return nil, fmt.Errorf("receipt: unsupported journal event type %q", event.Type) + } + } + + if r.Run.ID == "" { + return nil, fmt.Errorf("receipt: journal has no run identity") + } + if r.Skill.Name == "" { + return nil, fmt.Errorf("receipt: journal has no skill identity") + } + if r.Skill.BindingDigest == "" && !initializationFailed { + return nil, fmt.Errorf("receipt: started run has no binding digest") + } + r.Journal.RunID = r.Run.ID + r.Timing.StartedAt = formatTime(started) + r.Timing.LastObservedAt = formatTime(last) + if terminal { + r.Timing.EndedAt = formatTime(ended) + setDuration(&r.Timing.ElapsedMS, &r.Timing.ClockAnomaly, started, ended) + } + + sequences := make([]int, 0, len(operations)) + for sequence := range operations { + sequences = append(sequences, sequence) + } + sort.Ints(sequences) + summaries := map[protocol.OpKind]*OperationSummary{} + for _, sequence := range sequences { + operation := operations[sequence] + r.Operations = append(r.Operations, *operation) + summary := summaries[operation.Kind] + if summary == nil { + summary = &OperationSummary{Kind: operation.Kind} + summaries[operation.Kind] = summary + } + summary.Requested++ + if operation.CompletedAt != "" { + summary.Completed++ + } + if operation.ElapsedMS != nil { + summary.TotalElapsedMS += *operation.ElapsedMS + } + } + for _, kind := range []protocol.OpKind{protocol.OpAskUser, protocol.OpAgentTask, protocol.OpRunCommand} { + if summary := summaries[kind]; summary != nil { + r.OperationSummaries = append(r.OperationSummaries, *summary) + } + } + reasons := make([]string, 0, len(rejections)) + for reason := range rejections { + reasons = append(reasons, reason) + } + sort.Strings(reasons) + for _, reason := range reasons { + r.ResponseRejections = append(r.ResponseRejections, ResponseRejectionSummary{Reason: reason, Count: rejections[reason]}) + } + + switch { + case initializationFailed: + r.Outcome.Phase = "initialization_failed" + r.Outcome.FailureCode = initializationCode + case terminal: + r.Outcome.Phase = "terminal" + case recoverableFailed: + r.Outcome.Phase = "recoverable_error" + r.Outcome.FailureCode = executionCode + case lastDivergenceSeq > 0: + r.Outcome.Phase = "diverged" + case pendingOperation(r.Operations): + r.Outcome.Phase = "awaiting_response" + case r.Skill.BindingDigest == "": + r.Outcome.Phase = "initializing" + default: + r.Outcome.Phase = "advancing" + } + if r.Experiment != nil { + if err := r.Experiment.Validate(); err != nil { + return nil, fmt.Errorf("receipt: invalid experiment context: %w", err) + } + } + + if err := Seal(r); err != nil { + return nil, err + } + return r, nil +} + +func runtimeIdentity(supervisor, required string) *RuntimeIdentity { + if supervisor == "dev" { + supervisor = "" + } + if supervisor == "" && required == "" { + return nil + } + runtime := &RuntimeIdentity{SupervisorVersion: supervisor, RequiredVersion: required} + if supervisor != "" && required != "" { + compatible := supervisor == required + runtime.Compatible = &compatible + } + return runtime +} + +func pendingOperation(operations []OperationObservation) bool { + return len(operations) > 0 && operations[len(operations)-1].CompletedAt == "" +} + +func setDuration(target **int64, anomaly *bool, start, end time.Time) { + if start.IsZero() || end.IsZero() { + return + } + if end.Before(start) { + *anomaly = true + return + } + value := end.Sub(start).Milliseconds() + *target = &value +} + +func formatTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339Nano) +} + +func digest(domain string, value []byte) string { + h := sha256.New() + if domain != "" { + h.Write([]byte(domain)) + h.Write([]byte{0}) + } + h.Write(value) + return "sha256:" + hex.EncodeToString(h.Sum(nil)) +} + +// Seal sets the deterministic receipt digest. +func Seal(receipt *RunReceipt) error { + receipt.ReceiptDigest = "" + if err := receipt.Validate(); err != nil { + return err + } + body, err := CanonicalBytes(receipt) + if err != nil { + return err + } + receipt.ReceiptDigest = digest("", body) + return nil +} + +// VerifyCanonical proves that bytes are the canonical encoding named by the +// receipt digest. +func VerifyCanonical(receipt *RunReceipt, raw []byte) error { + if receipt == nil || receipt.ReceiptDigest == "" { + return fmt.Errorf("receipt: missing receipt digest") + } + want := receipt.ReceiptDigest + copy := *receipt + if err := Seal(©); err != nil { + return err + } + if copy.ReceiptDigest != want { + return fmt.Errorf("receipt: digest verification failed") + } + canonical, err := CanonicalBytes(©) + if err != nil { + return err + } + if !bytes.Equal(canonical, raw) { + return fmt.Errorf("receipt: bytes are not canonical JSON") + } + return nil +} + +// Validate checks the closed Go representation against the public contract. +func (receipt *RunReceipt) Validate() error { + if receipt == nil || receipt.Schema != Schema || receipt.Kind != Kind { + return fmt.Errorf("receipt: invalid schema or kind") + } + if receipt.Journal.RunID == "" || receipt.Journal.HeadSequence < 1 || !validDigest(receipt.Journal.HeadDigest) || receipt.Run.ID != receipt.Journal.RunID || receipt.Skill.Name == "" { + return fmt.Errorf("receipt: incomplete journal, run, or skill identity") + } + for _, value := range []string{receipt.ReceiptDigest, receipt.Run.InputDigest, receipt.Skill.BindingDigest, receipt.Outcome.ResultDigest} { + if value != "" && !validDigest(value) { + return fmt.Errorf("receipt: invalid digest") + } + } + if receipt.Skill.SourceDigest != nil { + if !sourceDigestProfile.MatchString(receipt.Skill.SourceDigest.Profile) || !validDigest(receipt.Skill.SourceDigest.Value) { + return fmt.Errorf("receipt: invalid source digest") + } + } + if receipt.Skill.Version != "" && !semanticVersion.MatchString(receipt.Skill.Version) { + return fmt.Errorf("receipt: invalid skill version") + } + if receipt.Runtime != nil { + if receipt.Runtime.SupervisorVersion == "" && receipt.Runtime.RequiredVersion == "" && receipt.Runtime.Compatible == nil { + return fmt.Errorf("receipt: empty runtime identity") + } + if receipt.Runtime.SupervisorVersion != "" && !semanticVersion.MatchString(receipt.Runtime.SupervisorVersion) || receipt.Runtime.RequiredVersion != "" && !semanticVersion.MatchString(receipt.Runtime.RequiredVersion) { + return fmt.Errorf("receipt: invalid runtime version") + } + if receipt.Runtime.Compatible != nil && (receipt.Runtime.SupervisorVersion == "" || receipt.Runtime.RequiredVersion == "") { + return fmt.Errorf("receipt: runtime compatibility lacks authoritative versions") + } + } + if receipt.Timing.StartedAt == "" || receipt.Timing.LastObservedAt == "" { + return fmt.Errorf("receipt: timing identity is incomplete") + } + for _, value := range []string{receipt.Timing.StartedAt, receipt.Timing.LastObservedAt, receipt.Timing.EndedAt} { + if value != "" { + if _, err := time.Parse(time.RFC3339Nano, value); err != nil { + return fmt.Errorf("receipt: invalid timestamp: %w", err) + } + } + } + if receipt.Timing.ElapsedMS != nil && *receipt.Timing.ElapsedMS < 0 { + return fmt.Errorf("receipt: negative elapsed time") + } + validPhase := map[string]bool{"initializing": true, "initialization_failed": true, "awaiting_response": true, "advancing": true, "recoverable_error": true, "diverged": true, "terminal": true} + if !validPhase[receipt.Outcome.Phase] { + return fmt.Errorf("receipt: invalid lifecycle phase %q", receipt.Outcome.Phase) + } + if receipt.Outcome.Phase == "terminal" { + validDisposition := map[string]bool{"completed": true, "blocked": true, "refused": true} + validCause := map[string]bool{"completed": true, "blocked": true, "refused": true, "requirement_failed": true, "completion_unproven": true} + if !validDisposition[receipt.Outcome.TerminalDisposition] || !validCause[receipt.Outcome.TerminalCause] { + return fmt.Errorf("receipt: invalid terminal outcome") + } + if receipt.Timing.EndedAt == "" { + return fmt.Errorf("receipt: terminal timing has no end") + } + } else if receipt.Outcome.TerminalDisposition != "" || receipt.Outcome.TerminalCause != "" { + return fmt.Errorf("receipt: nonterminal receipt has a terminal outcome") + } + validFailureCode := map[string]bool{ + "manifest_invalid": true, "manifest_read_failed": true, "runtime_version_missing": true, + "runtime_incompatible": true, "runner_missing": true, "source_lockfile_missing": true, + "source_digest_failed": true, "initialization_failed": true, "invalid_program_output": true, + "execution_timeout": true, "subprocess_failed": true, "execution_failed": true, + "command_execution_failed": true, + } + if receipt.Outcome.FailureCode != "" && !validFailureCode[receipt.Outcome.FailureCode] { + return fmt.Errorf("receipt: invalid failure code") + } + if (receipt.Outcome.Phase == "initialization_failed" || receipt.Outcome.Phase == "recoverable_error") && receipt.Outcome.FailureCode == "" { + return fmt.Errorf("receipt: failure phase has no code") + } + for _, operation := range receipt.Operations { + if operation.Sequence < 1 || !validOperationKind(operation.Kind) || !validDigest(operation.OperationKeyDigest) || operation.RequestedAt == "" || operation.ElapsedMS != nil && *operation.ElapsedMS < 0 || operation.ResultDigest != "" && !validDigest(operation.ResultDigest) { + return fmt.Errorf("receipt: invalid operation observation") + } + for _, value := range []string{operation.RequestedAt, operation.CompletedAt} { + if value != "" { + if _, err := time.Parse(time.RFC3339Nano, value); err != nil { + return fmt.Errorf("receipt: invalid operation timestamp") + } + } + } + } + for _, summary := range receipt.OperationSummaries { + if !validOperationKind(summary.Kind) || summary.Requested < 0 || summary.Completed < 0 || summary.Completed > summary.Requested || summary.TotalElapsedMS < 0 { + return fmt.Errorf("receipt: invalid operation summary") + } + } + for _, requirement := range receipt.Requirements { + if requirement.Outcome != "passed" && requirement.Outcome != "failed" || !validDigest(requirement.ClaimDigest) || requirement.EvidenceDigest != "" && !validDigest(requirement.EvidenceDigest) { + return fmt.Errorf("receipt: invalid requirement outcome") + } + } + validRejection := map[string]bool{"wrong-run": true, "stale-response": true, "duplicate-response": true, "wrong-request": true, "schema-invalid": true, "digest-mismatch": true, "completion-unproven": true, "run-closed": true, "no-pending-operation": true} + for _, rejection := range receipt.ResponseRejections { + if !validRejection[rejection.Reason] || rejection.Count < 1 { + return fmt.Errorf("receipt: invalid response rejection") + } + } + for _, divergence := range receipt.Divergences { + if divergence.Sequence < 1 || !validDigest(divergence.Expected) || !validDigest(divergence.Got) { + return fmt.Errorf("receipt: invalid divergence outcome") + } + } + if receipt.Experiment != nil { + if err := receipt.Experiment.Validate(); err != nil { + return err + } + } + return nil +} + +func validOperationKind(kind protocol.OpKind) bool { + return kind == protocol.OpAskUser || kind == protocol.OpAgentTask || kind == protocol.OpRunCommand +} + +func validDigest(value string) bool { + if len(value) != len("sha256:")+sha256.Size*2 || !strings.HasPrefix(value, "sha256:") { + return false + } + _, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:")) + return err == nil +} + +// CanonicalBytes returns RFC 8785-compatible JSON for the receipt's closed, +// integer-only data model. +func CanonicalBytes(value any) ([]byte, error) { + raw, err := json.Marshal(value) + if err != nil { + return nil, err + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var decoded any + if err := decoder.Decode(&decoded); err != nil { + return nil, err + } + var output bytes.Buffer + if err := writeCanonical(&output, decoded); err != nil { + return nil, err + } + return output.Bytes(), nil +} + +func writeCanonical(output *bytes.Buffer, value any) error { + switch typed := value.(type) { + case nil: + output.WriteString("null") + case bool: + output.WriteString(strconv.FormatBool(typed)) + case string: + encoded, err := marshalString(typed) + if err != nil { + return err + } + output.Write(encoded) + case json.Number: + text := typed.String() + if strings.ContainsAny(text, ".eE") { + return fmt.Errorf("receipt: non-integer JSON number %q is not supported", text) + } + if _, err := strconv.ParseInt(text, 10, 64); err != nil { + return fmt.Errorf("receipt: invalid integer %q", text) + } + output.WriteString(text) + case []any: + output.WriteByte('[') + for index, item := range typed { + if index > 0 { + output.WriteByte(',') + } + if err := writeCanonical(output, item); err != nil { + return err + } + } + output.WriteByte(']') + case map[string]any: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { return utf16Less(keys[i], keys[j]) }) + output.WriteByte('{') + for index, key := range keys { + if index > 0 { + output.WriteByte(',') + } + encoded, err := marshalString(key) + if err != nil { + return err + } + output.Write(encoded) + output.WriteByte(':') + if err := writeCanonical(output, typed[key]); err != nil { + return err + } + } + output.WriteByte('}') + default: + return fmt.Errorf("receipt: unsupported canonical JSON type %T", value) + } + return nil +} + +func marshalString(value string) ([]byte, error) { + if !utf8.ValidString(value) { + return nil, fmt.Errorf("receipt: invalid UTF-8 string") + } + var output bytes.Buffer + output.WriteByte('"') + for _, r := range value { + switch r { + case '"', '\\': + output.WriteByte('\\') + output.WriteRune(r) + case '\b': + output.WriteString(`\b`) + case '\t': + output.WriteString(`\t`) + case '\n': + output.WriteString(`\n`) + case '\f': + output.WriteString(`\f`) + case '\r': + output.WriteString(`\r`) + default: + if r < 0x20 { + fmt.Fprintf(&output, `\u%04x`, r) + } else { + output.WriteRune(r) + } + } + } + output.WriteByte('"') + return output.Bytes(), nil +} + +func utf16Less(left, right string) bool { + l := utf16.Encode([]rune(left)) + r := utf16.Encode([]rune(right)) + for index := 0; index < len(l) && index < len(r); index++ { + if l[index] != r[index] { + return l[index] < r[index] + } + } + return len(l) < len(r) +} diff --git a/internal/receipt/receipt_test.go b/internal/receipt/receipt_test.go new file mode 100644 index 0000000..9579a1b --- /dev/null +++ b/internal/receipt/receipt_test.go @@ -0,0 +1,221 @@ +package receipt + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/operatorstack/yield/internal/protocol" + "github.com/operatorstack/yield/internal/runlog" +) + +func TestProjectCompletedReceiptIsDeterministicAndPrivate(t *testing.T) { + t0 := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) + secret := "SECRET_PROMPT_COMMAND_STDOUT_TOKEN" + skill := protocol.SkillRef{Name: "review-release", Digest: protocol.DigestBytes([]byte("source"))} + envelope := protocol.RequestEnvelope{ + Protocol: protocol.Version, RunID: "run_test", Skill: skill, Sequence: 1, + Request: protocol.Request{ID: "review", Kind: protocol.OpAgentTask, Payload: json.RawMessage(`{"instruction":"` + secret + `"}`)}, + } + result := json.RawMessage(`{"answer":"` + secret + `"}`) + events := []runlog.Event{ + event(t, 1, runlog.RunStarted, t0, map[string]any{"run_id": "run_test", "skill": skill, "input_digest": protocol.DigestBytes([]byte(secret))}), + event(t, 2, runlog.OperationRequested, t0.Add(time.Second), envelope), + event(t, 3, runlog.OperationCompleted, t0.Add(4*time.Second), map[string]any{"sequence": 1, "request_id": "review", "result": result, "result_digest": protocol.DigestBytes(result)}), + event(t, 4, runlog.RequirementPassed, t0.Add(5*time.Second), protocol.Requirement{Claim: secret, Passed: true, EvidenceDigest: protocol.DigestBytes(result)}), + event(t, 5, runlog.RunCompleted, t0.Add(6*time.Second), map[string]any{"result": result, "requirements": 1}), + } + snapshot := Snapshot{Events: events, Bytes: journalBytes(t, events)} + first, err := Project(snapshot) + if err != nil { + t.Fatal(err) + } + second, err := Project(snapshot) + if err != nil { + t.Fatal(err) + } + firstBytes, _ := CanonicalBytes(first) + secondBytes, _ := CanonicalBytes(second) + if !bytes.Equal(firstBytes, secondBytes) || first.ReceiptDigest != second.ReceiptDigest { + t.Fatal("same journal prefix produced different receipt bytes") + } + if strings.Contains(string(firstBytes), secret) { + t.Fatalf("receipt leaked forbidden raw content: %s", firstBytes) + } + if first.Outcome.Phase != "terminal" || first.Outcome.TerminalDisposition != "completed" { + t.Fatalf("unexpected outcome: %+v", first.Outcome) + } + if first.Operations[0].ElapsedMS == nil || *first.Operations[0].ElapsedMS != 3000 { + t.Fatalf("unexpected operation timing: %+v", first.Operations[0]) + } +} + +func TestProjectLegacyJournalDoesNotInventSourceOrRuntime(t *testing.T) { + t0 := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) + skill := protocol.SkillRef{Name: "legacy", Digest: protocol.DigestBytes([]byte("legacy"))} + events := []runlog.Event{ + event(t, 1, runlog.RunStarted, t0, map[string]any{"run_id": "run_legacy", "skill": skill, "input_digest": protocol.DigestBytes(nil)}), + } + receipt, err := Project(Snapshot{Events: events, Bytes: journalBytes(t, events)}) + if err != nil { + t.Fatal(err) + } + if receipt.Runtime != nil || receipt.Skill.SourceDigest != nil { + t.Fatalf("legacy receipt invented facts: %+v", receipt) + } + if receipt.Outcome.Phase != "advancing" { + t.Fatalf("legacy phase = %q", receipt.Outcome.Phase) + } +} + +func TestProjectClockAnomalyOmitsDuration(t *testing.T) { + t0 := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) + skill := protocol.SkillRef{Name: "clock", Digest: protocol.DigestBytes([]byte("clock"))} + envelope := protocol.RequestEnvelope{Protocol: protocol.Version, RunID: "run_clock", Skill: skill, Sequence: 1, Request: protocol.Request{ID: "ask", Kind: protocol.OpAskUser, Payload: json.RawMessage(`{"question":"ok"}`)}} + events := []runlog.Event{ + event(t, 1, runlog.RunStarted, t0, map[string]any{"run_id": "run_clock", "skill": skill}), + event(t, 2, runlog.OperationRequested, t0.Add(time.Second), envelope), + event(t, 3, runlog.OperationCompleted, t0, map[string]any{"sequence": 1, "request_id": "ask", "result": json.RawMessage(`{"value":"yes"}`)}), + } + receipt, err := Project(Snapshot{Events: events, Bytes: journalBytes(t, events)}) + if err != nil { + t.Fatal(err) + } + if !receipt.Timing.ClockAnomaly || !receipt.Operations[0].ClockAnomaly || receipt.Operations[0].ElapsedMS != nil { + t.Fatalf("clock anomaly was not preserved: %+v", receipt.Operations[0]) + } +} + +func TestCanonicalBytesUsesJCSStringAndKeyRules(t *testing.T) { + raw, err := CanonicalBytes(map[string]any{"😀": "\u2028", "€": "<", "\r": "\n"}) + if err != nil { + t.Fatal(err) + } + want := "{\"\\r\":\"\\n\",\"€\":\"<\",\"😀\":\"\u2028\"}" + if string(raw) != want { + t.Fatalf("canonical JSON = %q, want %q", raw, want) + } +} + +func TestProjectLifecycleClassifications(t *testing.T) { + t0 := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) + digest := protocol.DigestBytes([]byte("source")) + skill := protocol.SkillRef{Name: "classify", Digest: digest} + started := func() runlog.Event { + return event(t, 1, runlog.RunStarted, t0, map[string]any{"run_id": "run_classify", "skill": skill}) + } + tests := []struct { + name string + events []runlog.Event + phase string + cause string + }{ + { + name: "initialization failed", + events: []runlog.Event{ + event(t, 1, runlog.RunOpened, t0, map[string]any{"run_id": "run_init", "skill_name": "classify", "input_digest": digest}), + event(t, 2, runlog.RunInitializationFailed, t0.Add(time.Second), map[string]any{"phase": "initialize", "code": "manifest_invalid"}), + }, + phase: "initialization_failed", + }, + { + name: "recoverable execution failure", + events: []runlog.Event{started(), event(t, 2, runlog.ExecutionFailed, t0.Add(time.Second), map[string]any{"code": "subprocess_failed"})}, + phase: "recoverable_error", + }, + { + name: "diverged with an unanswered prior frontier", + events: []runlog.Event{ + started(), + event(t, 2, runlog.OperationRequested, t0.Add(time.Second), protocol.RequestEnvelope{ + Protocol: protocol.Version, RunID: "run_classify", Skill: skill, Sequence: 1, + Request: protocol.Request{ID: "ask", Kind: protocol.OpAskUser}, + }), + event(t, 3, runlog.ReplayDiverged, t0.Add(2*time.Second), protocol.Divergence{Sequence: 1, Expected: digest, Got: protocol.DigestBytes([]byte("got")), Detail: "PRIVATE DETAIL"}), + }, + phase: "diverged", + }, + { + name: "blocked requirement", + events: []runlog.Event{started(), event(t, 2, runlog.RequirementFailed, t0.Add(time.Second), protocol.Requirement{Claim: "PRIVATE CLAIM"}), event(t, 3, runlog.RunBlocked, t0.Add(2*time.Second), map[string]any{"cause": "requirement_failed", "reason": "PRIVATE REASON"})}, + phase: "terminal", cause: "requirement_failed", + }, + { + name: "refused", + events: []runlog.Event{started(), event(t, 2, runlog.RunRefused, t0.Add(time.Second), map[string]any{"reason": "PRIVATE REASON"})}, + phase: "terminal", cause: "refused", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r, err := Project(Snapshot{Events: test.events, Bytes: journalBytes(t, test.events)}) + if err != nil { + t.Fatal(err) + } + if r.Outcome.Phase != test.phase || r.Outcome.TerminalCause != test.cause { + t.Fatalf("outcome = %+v", r.Outcome) + } + raw, _ := CanonicalBytes(r) + if strings.Contains(string(raw), "PRIVATE") { + t.Fatalf("receipt leaked private detail: %s", raw) + } + }) + } +} + +func TestProjectRejectsBrokenOperationPairing(t *testing.T) { + t0 := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) + digest := protocol.DigestBytes([]byte("source")) + skill := protocol.SkillRef{Name: "broken", Digest: digest} + events := []runlog.Event{ + event(t, 1, runlog.RunStarted, t0, map[string]any{"run_id": "run_broken", "skill": skill}), + event(t, 2, runlog.OperationCompleted, t0.Add(time.Second), map[string]any{"sequence": 1, "request_id": "missing", "result_digest": digest}), + } + if _, err := Project(Snapshot{Events: events, Bytes: journalBytes(t, events)}); err == nil { + t.Fatal("broken operation pairing was accepted") + } +} + +func TestProjectFailsClosedOnUnknownAndPostTerminalEvents(t *testing.T) { + t0 := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) + digest := protocol.DigestBytes([]byte("source")) + skill := protocol.SkillRef{Name: "closed", Digest: digest} + started := event(t, 1, runlog.RunStarted, t0, map[string]any{"run_id": "run_closed", "skill": skill}) + unknown := []runlog.Event{started, event(t, 2, runlog.EventType("future.event"), t0.Add(time.Second), map[string]any{})} + if _, err := Project(Snapshot{Events: unknown, Bytes: journalBytes(t, unknown)}); err == nil { + t.Fatal("unknown event was silently omitted") + } + postTerminal := []runlog.Event{ + started, + event(t, 2, runlog.RunRefused, t0.Add(time.Second), map[string]any{}), + event(t, 3, runlog.RunCompleted, t0.Add(2*time.Second), map[string]any{}), + } + if _, err := Project(Snapshot{Events: postTerminal, Bytes: journalBytes(t, postTerminal)}); err == nil { + t.Fatal("event after terminal was accepted") + } +} + +func event(t *testing.T, sequence int, kind runlog.EventType, at time.Time, data any) runlog.Event { + t.Helper() + raw, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + return runlog.Event{Seq: sequence, Type: kind, At: at, Data: raw} +} + +func journalBytes(t *testing.T, events []runlog.Event) []byte { + t.Helper() + var output bytes.Buffer + for _, item := range events { + raw, err := json.Marshal(item) + if err != nil { + t.Fatal(err) + } + output.Write(raw) + output.WriteByte('\n') + } + return output.Bytes() +} diff --git a/internal/receipt/report.go b/internal/receipt/report.go new file mode 100644 index 0000000..df67fa8 --- /dev/null +++ b/internal/receipt/report.go @@ -0,0 +1,137 @@ +package receipt + +import ( + "fmt" + "sort" + "time" + + "github.com/operatorstack/yield/internal/protocol" +) + +type ReportOptions struct { + From time.Time + To time.Time + OpenAgeThreshold time.Duration + ReferenceTime time.Time + ExperimentID string +} + +type LocalReport struct { + ReceiptCount int `json:"receipt_count"` + Lifecycle []NamedCount `json:"lifecycle"` + Terminal []NamedCount `json:"terminal"` + Operations []ReportOperationSummary `json:"operations"` + ResponseRejections []NamedCount `json:"response_rejections"` + Requirements []NamedCount `json:"requirements"` + DivergenceCount int `json:"divergence_count"` + RuntimeGroups []NamedCount `json:"runtime_groups"` + SourceGroups []NamedCount `json:"source_groups"` + ExperimentGroups []NamedCount `json:"experiment_groups"` + OpenOlderThanThreshold int `json:"open_older_than_threshold,omitempty"` +} + +type NamedCount struct { + Name string `json:"name"` + Count int `json:"count"` +} + +type ReportOperationSummary struct { + Kind protocol.OpKind `json:"kind"` + Requested int `json:"requested"` + Completed int `json:"completed"` + TotalElapsedMS int64 `json:"total_elapsed_ms"` +} + +func BuildReport(receipts []*RunReceipt, options ReportOptions) (LocalReport, error) { + report := LocalReport{ + Lifecycle: []NamedCount{}, Terminal: []NamedCount{}, Operations: []ReportOperationSummary{}, + ResponseRejections: []NamedCount{}, Requirements: []NamedCount{}, RuntimeGroups: []NamedCount{}, + SourceGroups: []NamedCount{}, ExperimentGroups: []NamedCount{}, + } + lifecycle := map[string]int{} + terminal := map[string]int{} + rejections := map[string]int{} + requirements := map[string]int{} + runtimes := map[string]int{} + sources := map[string]int{} + experiments := map[string]int{} + operations := map[protocol.OpKind]*ReportOperationSummary{} + for _, r := range receipts { + started, err := time.Parse(time.RFC3339Nano, r.Timing.StartedAt) + if err != nil { + return LocalReport{}, fmt.Errorf("report: receipt %s has invalid start time: %w", r.Run.ID, err) + } + if !options.From.IsZero() && started.Before(options.From) || !options.To.IsZero() && started.After(options.To) { + continue + } + if options.ExperimentID != "" && (r.Experiment == nil || r.Experiment.ExperimentID != options.ExperimentID) { + continue + } + report.ReceiptCount++ + lifecycle[r.Outcome.Phase]++ + if r.Outcome.TerminalDisposition != "" { + terminal[r.Outcome.TerminalDisposition]++ + } + for _, summary := range r.OperationSummaries { + aggregate := operations[summary.Kind] + if aggregate == nil { + aggregate = &ReportOperationSummary{Kind: summary.Kind} + operations[summary.Kind] = aggregate + } + aggregate.Requested += summary.Requested + aggregate.Completed += summary.Completed + aggregate.TotalElapsedMS += summary.TotalElapsedMS + } + for _, rejection := range r.ResponseRejections { + rejections[rejection.Reason] += rejection.Count + } + for _, requirement := range r.Requirements { + requirements[requirement.Outcome]++ + } + report.DivergenceCount += len(r.Divergences) + if r.Runtime != nil { + runtimes[r.Runtime.SupervisorVersion+"|"+r.Runtime.RequiredVersion]++ + } + if r.Skill.SourceDigest != nil { + sources[r.Skill.SourceDigest.Profile+"|"+r.Skill.SourceDigest.Value]++ + } + if r.Experiment != nil { + experiments[r.Experiment.ExperimentID+"|"+r.Experiment.VariantID+"|"+r.Experiment.Role]++ + } + if options.OpenAgeThreshold > 0 && r.Outcome.Phase != "terminal" && r.Outcome.Phase != "initialization_failed" { + reference := options.ReferenceTime + if reference.IsZero() { + reference = options.To + } + if !reference.IsZero() && reference.Sub(started) > options.OpenAgeThreshold { + report.OpenOlderThanThreshold++ + } + } + } + report.Lifecycle = namedCounts(lifecycle) + report.Terminal = namedCounts(terminal) + report.ResponseRejections = namedCounts(rejections) + report.Requirements = namedCounts(requirements) + report.RuntimeGroups = namedCounts(runtimes) + report.SourceGroups = namedCounts(sources) + report.ExperimentGroups = namedCounts(experiments) + for _, kind := range []protocol.OpKind{protocol.OpAskUser, protocol.OpAgentTask, protocol.OpRunCommand} { + if summary := operations[kind]; summary != nil { + report.Operations = append(report.Operations, *summary) + } + } + return report, nil +} + +func namedCounts(values map[string]int) []NamedCount { + names := make([]string, 0, len(values)) + for name := range values { + names = append(names, name) + } + sort.Strings(names) + result := make([]NamedCount, 0, len(names)) + for _, name := range names { + result = append(result, NamedCount{Name: name, Count: values[name]}) + } + return result +} diff --git a/internal/receipt/report_test.go b/internal/receipt/report_test.go new file mode 100644 index 0000000..c323093 --- /dev/null +++ b/internal/receipt/report_test.go @@ -0,0 +1,36 @@ +package receipt + +import ( + "reflect" + "testing" + "time" + + "github.com/operatorstack/yield/internal/protocol" +) + +func TestBuildReportIsDeterministicAndMakesNoCausalClaim(t *testing.T) { + r := &RunReceipt{ + Run: RunIdentity{ID: "run_1"}, Timing: TimingSummary{StartedAt: "2026-08-20T10:00:00Z"}, + Outcome: OutcomeSummary{Phase: "awaiting_response"}, + OperationSummaries: []OperationSummary{{Kind: protocol.OpAskUser, Requested: 1}}, + Experiment: &ExperimentContext{ExperimentID: "exp-1", VariantID: "candidate", Role: "candidate"}, + } + options := ReportOptions{ + From: time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC), + OpenAgeThreshold: time.Hour, ReferenceTime: time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC), + } + first, err := BuildReport([]*RunReceipt{r}, options) + if err != nil { + t.Fatal(err) + } + second, err := BuildReport([]*RunReceipt{r}, options) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(first, second) { + t.Fatal("same receipts and window produced different reports") + } + if first.OpenOlderThanThreshold != 1 || first.ExperimentGroups[0].Count != 1 { + t.Fatalf("unexpected report: %+v", first) + } +} diff --git a/internal/receipt/store.go b/internal/receipt/store.go new file mode 100644 index 0000000..ed09246 --- /dev/null +++ b/internal/receipt/store.go @@ -0,0 +1,281 @@ +package receipt + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" +) + +// Store materializes immutable receipt objects and mutable per-run references. +type Store struct { + Root string +} + +func NewStore(yieldDir string) *Store { + return &Store{Root: filepath.Join(yieldDir, "receipts")} +} + +func StoreForRunsDir(runsDir string) *Store { + return NewStore(filepath.Dir(runsDir)) +} + +// Materialize projects and durably stores the receipt for one exact prefix. +func (s *Store) Materialize(snapshot Snapshot) (*RunReceipt, []byte, error) { + r, err := Project(snapshot) + if err != nil { + return nil, nil, err + } + raw, err := CanonicalBytes(r) + if err != nil { + return nil, nil, err + } + if err := s.Put(r, raw); err != nil { + return nil, nil, err + } + return r, raw, nil +} + +// Put durably stores an already-sealed receipt. +func (s *Store) Put(r *RunReceipt, raw []byte) error { + if r == nil || r.ReceiptDigest == "" || r.Run.ID == "" { + return fmt.Errorf("receipt store: incomplete receipt") + } + if err := r.Validate(); err != nil { + return err + } + if err := VerifyCanonical(r, raw); err != nil { + return err + } + digest := strings.TrimPrefix(r.ReceiptDigest, "sha256:") + if len(digest) != 64 { + return fmt.Errorf("receipt store: invalid receipt digest") + } + objects := filepath.Join(s.Root, "objects", "sha256", digest[:2]) + references := filepath.Join(s.Root, "runs") + if err := secureMkdirAll(objects); err != nil { + return err + } + if err := secureMkdirAll(references); err != nil { + return err + } + objectPath := filepath.Join(objects, digest+".json") + if err := writeImmutable(objectPath, raw); err != nil { + return err + } + return writeReplace(filepath.Join(references, r.Run.ID+".ref"), []byte(r.ReceiptDigest+"\n")) +} + +// LoadRun reads the latest materialized receipt for a run. +func (s *Store) LoadRun(runID string) (*RunReceipt, []byte, error) { + ref, err := os.ReadFile(filepath.Join(s.Root, "runs", runID+".ref")) + if err != nil { + return nil, nil, err + } + digest := strings.TrimSpace(string(ref)) + return s.LoadDigest(digest) +} + +// LoadDigest reads and verifies an immutable receipt object. +func (s *Store) LoadDigest(receiptDigest string) (*RunReceipt, []byte, error) { + if !validDigest(receiptDigest) { + return nil, nil, fmt.Errorf("receipt store: invalid receipt digest") + } + hexDigest := strings.TrimPrefix(receiptDigest, "sha256:") + raw, err := os.ReadFile(filepath.Join(s.Root, "objects", "sha256", hexDigest[:2], hexDigest+".json")) + if err != nil { + return nil, nil, err + } + var r RunReceipt + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&r); err != nil { + return nil, nil, fmt.Errorf("receipt store: decode object: %w", err) + } + if err := expectEOF(decoder); err != nil { + return nil, nil, err + } + if r.ReceiptDigest != receiptDigest { + return nil, nil, fmt.Errorf("receipt store: reference and object digest differ") + } + if err := VerifyCanonical(&r, raw); err != nil { + return nil, nil, err + } + return &r, raw, nil +} + +// ListRuns returns run IDs with materialized latest-receipt references. +func (s *Store) ListRuns() ([]string, error) { + entries, err := os.ReadDir(filepath.Join(s.Root, "runs")) + if errors.Is(err, os.ErrNotExist) { + return []string{}, nil + } + if err != nil { + return nil, err + } + var ids []string + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".ref") { + ids = append(ids, strings.TrimSuffix(entry.Name(), ".ref")) + } + } + sort.Strings(ids) + return ids, nil +} + +func writeImmutable(path string, content []byte) error { + if existing, err := os.ReadFile(path); err == nil { + if !bytes.Equal(existing, content) { + return fmt.Errorf("receipt store: digest collision at %s", path) + } + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".receipt-object-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(content); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Link(tmpPath, path); err != nil { + if existing, readErr := os.ReadFile(path); readErr == nil { + if !bytes.Equal(existing, content) { + return fmt.Errorf("receipt store: digest collision at %s", path) + } + return nil + } + return err + } + return syncDir(filepath.Dir(path)) +} + +func writeReplace(path string, content []byte) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".receipt-ref-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(content); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return err + } + return syncDir(filepath.Dir(path)) +} + +func secureMkdirAll(path string) error { + if err := os.MkdirAll(path, 0o700); err != nil { + return err + } + return os.Chmod(path, 0o700) +} + +func syncDir(path string) error { + if runtime.GOOS == "windows" { + return nil + } + dir, err := os.Open(path) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} + +func expectEOF(decoder *json.Decoder) error { + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + err = fmt.Errorf("multiple JSON values") + } + return fmt.Errorf("receipt store: trailing content: %w", err) + } + return nil +} + +var experimentIdentifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) +var semanticVersion = regexp.MustCompile(`^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$`) +var sourceDigestProfile = regexp.MustCompile(`^yield\.skill-source\.v[0-9]+$`) + +// DecodeExperiment admits a closed, non-personal experiment context. +func DecodeExperiment(raw []byte) (*ExperimentContext, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var context ExperimentContext + if err := decoder.Decode(&context); err != nil { + return nil, fmt.Errorf("experiment does not decode: %w", err) + } + if err := expectEOF(decoder); err != nil { + return nil, err + } + if err := context.Validate(); err != nil { + return nil, err + } + return &context, nil +} + +func (context ExperimentContext) Validate() error { + for name, value := range map[string]string{ + "experiment_id": context.ExperimentID, + "variant_id": context.VariantID, + } { + if !validExperimentIdentifier(value) { + return fmt.Errorf("experiment %s is not a portable identifier", name) + } + } + for name, value := range map[string]string{ + "cohort_id": context.CohortID, + "baseline_variant_id": context.BaselineVariantID, + } { + if value != "" && !validExperimentIdentifier(value) { + return fmt.Errorf("experiment %s is not a portable identifier", name) + } + } + if context.Role != "baseline" && context.Role != "candidate" { + return fmt.Errorf("experiment role must be baseline or candidate") + } + if context.ParentSkillVersion != "" && !semanticVersion.MatchString(context.ParentSkillVersion) { + return fmt.Errorf("experiment parent_skill_version must be an exact semantic version") + } + return nil +} + +func validExperimentIdentifier(value string) bool { + return len(value) > 0 && len(value) <= 128 && experimentIdentifier.MatchString(value) +} diff --git a/internal/receipt/store_test.go b/internal/receipt/store_test.go new file mode 100644 index 0000000..4132a4e --- /dev/null +++ b/internal/receipt/store_test.go @@ -0,0 +1,120 @@ +package receipt + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/operatorstack/yield/internal/protocol" + "github.com/operatorstack/yield/internal/runlog" +) + +func TestStoreMaterializationConverges(t *testing.T) { + t0 := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) + skill := protocol.SkillRef{Name: "store", Digest: protocol.DigestBytes([]byte("store"))} + events := []runlog.Event{event(t, 1, runlog.RunStarted, t0, map[string]any{"run_id": "run_store", "skill": skill})} + snapshot := Snapshot{Events: events, Bytes: journalBytes(t, events)} + store := NewStore(t.TempDir()) + first, firstBytes, err := store.Materialize(snapshot) + if err != nil { + t.Fatal(err) + } + second, secondBytes, err := store.Materialize(snapshot) + if err != nil { + t.Fatal(err) + } + if first.ReceiptDigest != second.ReceiptDigest || !bytes.Equal(firstBytes, secondBytes) { + t.Fatal("rematerialization did not converge") + } + loaded, loadedBytes, err := store.LoadRun("run_store") + if err != nil { + t.Fatal(err) + } + if loaded.ReceiptDigest != first.ReceiptDigest || !bytes.Equal(loadedBytes, firstBytes) { + t.Fatal("loaded object differs from materialized object") + } + if runtime.GOOS != "windows" { + digest := first.ReceiptDigest[len("sha256:"):] + for _, path := range []string{ + filepath.Join(store.Root, "objects", "sha256", digest[:2], digest+".json"), + filepath.Join(store.Root, "runs", "run_store.ref"), + } { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("%s permissions = %o", path, info.Mode().Perm()) + } + } + } +} + +func TestStoreRepairsObjectWithoutRunReference(t *testing.T) { + t0 := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) + skill := protocol.SkillRef{Name: "store", Digest: protocol.DigestBytes([]byte("store"))} + events := []runlog.Event{event(t, 1, runlog.RunStarted, t0, map[string]any{"run_id": "run_store", "skill": skill})} + snapshot := Snapshot{Events: events, Bytes: journalBytes(t, events)} + store := NewStore(t.TempDir()) + r, _, err := store.Materialize(snapshot) + if err != nil { + t.Fatal(err) + } + ref := filepath.Join(store.Root, "runs", "run_store.ref") + if err := os.Remove(ref); err != nil { + t.Fatal(err) + } + if _, _, err := store.Materialize(snapshot); err != nil { + t.Fatal(err) + } + loaded, _, err := store.LoadRun("run_store") + if err != nil || loaded.ReceiptDigest != r.ReceiptDigest { + t.Fatalf("orphaned object was not repaired: %+v, %v", loaded, err) + } +} + +func TestStoreRejectsContentMismatchAtDigestPath(t *testing.T) { + t0 := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) + skill := protocol.SkillRef{Name: "store", Digest: protocol.DigestBytes([]byte("store"))} + events := []runlog.Event{event(t, 1, runlog.RunStarted, t0, map[string]any{"run_id": "run_store", "skill": skill})} + store := NewStore(t.TempDir()) + r, raw, err := store.Materialize(Snapshot{Events: events, Bytes: journalBytes(t, events)}) + if err != nil { + t.Fatal(err) + } + digest := r.ReceiptDigest[len("sha256:"):] + path := filepath.Join(store.Root, "objects", "sha256", digest[:2], digest+".json") + if err := os.WriteFile(path, []byte("different"), 0o600); err != nil { + t.Fatal(err) + } + if err := store.Put(r, raw); err == nil { + t.Fatal("content mismatch was accepted") + } +} + +func TestStoreRejectsUnsealedReceiptDigest(t *testing.T) { + t0 := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) + skill := protocol.SkillRef{Name: "store", Digest: protocol.DigestBytes([]byte("store"))} + events := []runlog.Event{event(t, 1, runlog.RunStarted, t0, map[string]any{"run_id": "run_store", "skill": skill})} + r, err := Project(Snapshot{Events: events, Bytes: journalBytes(t, events)}) + if err != nil { + t.Fatal(err) + } + raw, _ := CanonicalBytes(r) + r.ReceiptDigest = protocol.DigestBytes([]byte("wrong")) + if err := NewStore(t.TempDir()).Put(r, raw); err == nil { + t.Fatal("store accepted a receipt whose digest did not name its bytes") + } +} + +func TestDecodeExperimentIsClosed(t *testing.T) { + if _, err := DecodeExperiment([]byte(`{"experiment_id":"exp-1","variant_id":"candidate-a","role":"candidate"}`)); err != nil { + t.Fatal(err) + } + if _, err := DecodeExperiment([]byte(`{"experiment_id":"exp-1","variant_id":"candidate-a","role":"candidate","person":"alice"}`)); err == nil { + t.Fatal("unknown personal field was accepted") + } +} diff --git a/internal/runlog/runlog.go b/internal/runlog/runlog.go index b3ca116..8fdde63 100644 --- a/internal/runlog/runlog.go +++ b/internal/runlog/runlog.go @@ -5,27 +5,33 @@ package runlog import ( "bufio" + "bytes" "encoding/json" + "errors" "fmt" "os" "path/filepath" + "runtime" "time" ) type EventType string const ( - RunStarted EventType = "run.started" - OperationRequested EventType = "operation.requested" - OperationCompleted EventType = "operation.completed" - ResponseRejected EventType = "response.rejected" - RequirementPassed EventType = "requirement.passed" - RequirementFailed EventType = "requirement.failed" - DigestMigrated EventType = "digest.migrated" - ReplayDiverged EventType = "replay.diverged" - RunCompleted EventType = "run.completed" - RunBlocked EventType = "run.blocked" - RunRefused EventType = "run.refused" + RunOpened EventType = "run.opened" + RunStarted EventType = "run.started" + OperationRequested EventType = "operation.requested" + OperationCompleted EventType = "operation.completed" + ResponseRejected EventType = "response.rejected" + RequirementPassed EventType = "requirement.passed" + RequirementFailed EventType = "requirement.failed" + DigestMigrated EventType = "digest.migrated" + ReplayDiverged EventType = "replay.diverged" + ExecutionFailed EventType = "execution.failed" + RunInitializationFailed EventType = "run.initialization_failed" + RunCompleted EventType = "run.completed" + RunBlocked EventType = "run.blocked" + RunRefused EventType = "run.refused" ) // Event is one appended fact. Seq is monotone from 1 within a run. @@ -46,34 +52,70 @@ type Log struct { // or the cwd), creating it if needed. func RunsDir(root string) (string, error) { dir := filepath.Join(root, ".yield", "runs") - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o700); err != nil { return "", err } + _ = os.Chmod(dir, 0o700) return dir, nil } // Create starts a new log file for a run. It refuses to overwrite. func Create(runsDir, runID string) (*Log, error) { path := filepath.Join(runsDir, runID+".jsonl") - if _, err := os.Stat(path); err == nil { - return nil, fmt.Errorf("run log already exists: %s", path) + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + if errors.Is(err, os.ErrExist) { + return nil, fmt.Errorf("run log already exists: %s", path) + } + return nil, err } - if err := os.WriteFile(path, nil, 0o644); err != nil { + if err := f.Close(); err != nil { return nil, err } + if runtime.GOOS != "windows" { + dir, err := os.Open(runsDir) + if err != nil { + return nil, err + } + if err := dir.Sync(); err != nil { + dir.Close() + return nil, err + } + if err := dir.Close(); err != nil { + return nil, err + } + } return &Log{Path: path}, nil } // Open loads an existing run log and verifies sequence monotonicity. func Open(runsDir, runID string) (*Log, error) { path := filepath.Join(runsDir, runID+".jsonl") - f, err := os.Open(path) + raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("no such run: %s: %w", runID, err) } - defer f.Close() + return parse(path, raw) +} + +// OpenSnapshot reads and parses one exact journal prefix. The returned bytes +// and events always describe the same prefix. +func OpenSnapshot(runsDir, runID string) (*Log, []byte, error) { + path := filepath.Join(runsDir, runID+".jsonl") + raw, err := os.ReadFile(path) + if err != nil { + return nil, nil, fmt.Errorf("no such run: %s: %w", runID, err) + } + l, err := parse(path, raw) + if err != nil { + return nil, nil, err + } + return l, raw, nil +} + +func parse(path string, raw []byte) (*Log, error) { l := &Log{Path: path} - sc := bufio.NewScanner(f) + sc := bufio.NewScanner(bytes.NewReader(raw)) sc.Buffer(make([]byte, 0, 1024*1024), 16*1024*1024) line := 0 for sc.Scan() { @@ -107,7 +149,7 @@ func (l *Log) Append(t EventType, data any) (Event, error) { if err != nil { return Event{}, err } - f, err := os.OpenFile(l.Path, os.O_WRONLY|os.O_APPEND, 0o644) + f, err := os.OpenFile(l.Path, os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { return Event{}, err } diff --git a/ir/README.md b/ir/README.md index 410aff3..3cd9446 100644 --- a/ir/README.md +++ b/ir/README.md @@ -1,11 +1,13 @@ -# yield.v1 IR — the canonical protocol surface +# Public intermediate representations -This directory is the language-neutral definition of everything that -crosses a Yield process boundary. The Go types in `internal/protocol` are -the reference implementation; `internal/protocol/ir_test.go` binds them to -these schemas so the IR cannot drift from the runtime. Every language SDK -(`sdk/typescript`, `sdk/python`, `sdk/yield` for Go) implements this -surface and nothing else. +This directory contains Yield's language-neutral schemas. + +- `yield.v1` is the execution protocol between an SDK and the supervisor. +- `yield.observation.v1` is the portable observation boundary projected by + the Go supervisor from an append-only run journal. SDKs do not implement it. + +The Go reference types and schema tests keep both boundaries aligned with the +runtime. ## Files @@ -50,3 +52,10 @@ in every language. Wall clocks, RNGs, environment reads, and filesystem state are side effects: cross them through a yielded operation or leave them out. The contract detects divergence; it cannot prevent nondeterminism. + +## Observation contract + +`yield.observation.v1/run-receipt.schema.json` defines one privacy-safe +`RunReceipt`. A receipt is a deterministic projection of an exact journal +prefix. The journal remains authoritative, and replay never reads receipts or +export state. See [portable run receipts](../docs/reference/run-receipts.md). diff --git a/ir/yield.observation.v1/run-receipt.schema.json b/ir/yield.observation.v1/run-receipt.schema.json new file mode 100644 index 0000000..68d7882 --- /dev/null +++ b/ir/yield.observation.v1/run-receipt.schema.json @@ -0,0 +1,234 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://operatorstack.dev/yield/ir/yield.observation.v1/run-receipt.schema.json", + "title": "Yield RunReceipt", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "kind", + "receipt_digest", + "journal", + "run", + "skill", + "timing", + "operations", + "operation_summaries", + "outcome", + "requirements", + "response_rejections", + "divergences" + ], + "properties": { + "schema": { "const": "yield.observation.v1" }, + "kind": { "const": "run_receipt" }, + "receipt_digest": { "$ref": "#/$defs/digest" }, + "journal": { "$ref": "#/$defs/journal" }, + "run": { "$ref": "#/$defs/run" }, + "skill": { "$ref": "#/$defs/skill" }, + "runtime": { "$ref": "#/$defs/runtime" }, + "timing": { "$ref": "#/$defs/timing" }, + "operations": { "type": "array", "items": { "$ref": "#/$defs/operation" } }, + "operation_summaries": { "type": "array", "items": { "$ref": "#/$defs/operationSummary" } }, + "outcome": { "$ref": "#/$defs/outcome" }, + "requirements": { "type": "array", "items": { "$ref": "#/$defs/requirement" } }, + "response_rejections": { "type": "array", "items": { "$ref": "#/$defs/rejection" } }, + "divergences": { "type": "array", "items": { "$ref": "#/$defs/divergence" } }, + "experiment": { "$ref": "#/$defs/experiment" } + }, + "$defs": { + "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "semanticVersion": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(?:[-+][0-9A-Za-z.-]+)?$" + }, + "timestamp": { "type": "string", "format": "date-time" }, + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "journal": { + "type": "object", + "additionalProperties": false, + "required": ["run_id", "head_sequence", "head_digest"], + "properties": { + "run_id": { "type": "string", "minLength": 1 }, + "head_sequence": { "type": "integer", "minimum": 1 }, + "head_digest": { "$ref": "#/$defs/digest" } + } + }, + "run": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "input_digest": { "$ref": "#/$defs/digest" } + } + }, + "profileDigest": { + "type": "object", + "additionalProperties": false, + "required": ["profile", "value"], + "properties": { + "profile": { "type": "string", "pattern": "^yield\\.skill-source\\.v[0-9]+$" }, + "value": { "$ref": "#/$defs/digest" } + } + }, + "skill": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "version": { "$ref": "#/$defs/semanticVersion" }, + "binding_digest": { "$ref": "#/$defs/digest" }, + "source_digest": { "$ref": "#/$defs/profileDigest" } + } + }, + "runtime": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "supervisor_version": { "$ref": "#/$defs/semanticVersion" }, + "required_version": { "$ref": "#/$defs/semanticVersion" }, + "compatible": { "type": "boolean" } + } + }, + "timing": { + "type": "object", + "additionalProperties": false, + "required": ["started_at", "last_observed_at"], + "properties": { + "started_at": { "$ref": "#/$defs/timestamp" }, + "last_observed_at": { "$ref": "#/$defs/timestamp" }, + "ended_at": { "$ref": "#/$defs/timestamp" }, + "elapsed_ms": { "type": "integer", "minimum": 0 }, + "clock_anomaly": { "type": "boolean" } + } + }, + "operation": { + "type": "object", + "additionalProperties": false, + "required": ["sequence", "kind", "operation_key_digest", "requested_at"], + "properties": { + "sequence": { "type": "integer", "minimum": 1 }, + "kind": { "enum": ["ask_user", "agent_task", "run_command"] }, + "operation_key_digest": { "$ref": "#/$defs/digest" }, + "requested_at": { "$ref": "#/$defs/timestamp" }, + "completed_at": { "$ref": "#/$defs/timestamp" }, + "elapsed_ms": { "type": "integer", "minimum": 0 }, + "result_digest": { "$ref": "#/$defs/digest" }, + "clock_anomaly": { "type": "boolean" } + } + }, + "operationSummary": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "requested", "completed", "total_elapsed_ms"], + "properties": { + "kind": { "enum": ["ask_user", "agent_task", "run_command"] }, + "requested": { "type": "integer", "minimum": 0 }, + "completed": { "type": "integer", "minimum": 0 }, + "total_elapsed_ms": { "type": "integer", "minimum": 0 } + } + }, + "outcome": { + "type": "object", + "additionalProperties": false, + "required": ["phase"], + "properties": { + "phase": { + "enum": [ + "initializing", + "initialization_failed", + "awaiting_response", + "advancing", + "recoverable_error", + "diverged", + "terminal" + ] + }, + "terminal_disposition": { "enum": ["completed", "blocked", "refused"] }, + "terminal_cause": { + "enum": ["completed", "blocked", "refused", "requirement_failed", "completion_unproven"] + }, + "result_digest": { "$ref": "#/$defs/digest" }, + "failure_code": { + "enum": [ + "manifest_invalid", + "manifest_read_failed", + "runtime_version_missing", + "runtime_incompatible", + "runner_missing", + "source_lockfile_missing", + "source_digest_failed", + "initialization_failed", + "invalid_program_output", + "execution_timeout", + "subprocess_failed", + "execution_failed", + "command_execution_failed" + ] + } + } + }, + "requirement": { + "type": "object", + "additionalProperties": false, + "required": ["outcome", "claim_digest"], + "properties": { + "outcome": { "enum": ["passed", "failed"] }, + "claim_digest": { "$ref": "#/$defs/digest" }, + "evidence_digest": { "$ref": "#/$defs/digest" } + } + }, + "rejection": { + "type": "object", + "additionalProperties": false, + "required": ["reason", "count"], + "properties": { + "reason": { + "enum": [ + "wrong-run", + "stale-response", + "duplicate-response", + "wrong-request", + "schema-invalid", + "digest-mismatch", + "completion-unproven", + "run-closed", + "no-pending-operation" + ] + }, + "count": { "type": "integer", "minimum": 1 } + } + }, + "divergence": { + "type": "object", + "additionalProperties": false, + "required": ["sequence", "expected_digest", "got_digest"], + "properties": { + "sequence": { "type": "integer", "minimum": 1 }, + "expected_digest": { "$ref": "#/$defs/digest" }, + "got_digest": { "$ref": "#/$defs/digest" } + } + }, + "experiment": { + "type": "object", + "additionalProperties": false, + "required": ["experiment_id", "variant_id", "role"], + "properties": { + "experiment_id": { "$ref": "#/$defs/identifier" }, + "cohort_id": { "$ref": "#/$defs/identifier" }, + "variant_id": { "$ref": "#/$defs/identifier" }, + "role": { "enum": ["baseline", "candidate"] }, + "baseline_variant_id": { "$ref": "#/$defs/identifier" }, + "parent_skill_version": { "$ref": "#/$defs/semanticVersion" } + } + } + } +} diff --git a/release-notes/2026-08-20-portable-run-receipts.md b/release-notes/2026-08-20-portable-run-receipts.md new file mode 100644 index 0000000..164349e --- /dev/null +++ b/release-notes/2026-08-20-portable-run-receipts.md @@ -0,0 +1,10 @@ +# Portable run receipts and deferred export + +- Add the public `yield.observation.v1` RunReceipt schema and deterministic + journal projection. +- Materialize privacy-safe, content-addressed receipts at foreground stopping + points without changing replay or workflow results. +- Add explicit receipt inspection, deferred command-sink delivery, retry and + status operations, and deterministic local reports. +- Record versioned source identity, runtime identity, typed initialization and + execution failures, and optional experiment groups. From 34b4cca410a9c1acc8c512e67f306b31a7aa22be Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 20 Aug 2026 09:25:01 +0100 Subject: [PATCH 2/3] Fix Rust workspace lockfile compatibility --- internal/engine/engine.go | 7 +-- internal/engine/engine_test.go | 43 ++++++++++++++----- internal/receipt/receipt.go | 2 +- .../run-receipt.schema.json | 1 - 4 files changed, 34 insertions(+), 19 deletions(-) diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 8a8ae0e..74bad07 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -769,11 +769,6 @@ func (e *Engine) prepareRun() (protocol.SkillRef, string, error) { default: return protocol.SkillRef{}, "", fmt.Errorf("manifest_invalid: language is unsupported") } - if manifest.Language == "rust" { - if _, err := os.Stat(filepath.Join(e.SkillDir, "Cargo.lock")); err != nil { - return protocol.SkillRef{}, "", fmt.Errorf("source_lockfile_missing") - } - } if e.SupervisorVersion == "" { return protocol.SkillRef{}, "", fmt.Errorf("runtime_version_missing") } @@ -801,7 +796,7 @@ func (e *Engine) currentDigest(profile string) (string, error) { func initializationCode(err error) string { text := err.Error() - for _, code := range []string{"manifest_invalid", "manifest_read_failed", "runtime_version_missing", "runtime_incompatible", "runner_missing", "source_lockfile_missing", "source_digest_failed"} { + for _, code := range []string{"manifest_invalid", "manifest_read_failed", "runtime_version_missing", "runtime_incompatible", "runner_missing", "source_digest_failed"} { if strings.HasPrefix(text, code) { return code } diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 120e859..adf861a 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -94,28 +94,49 @@ func TestInitializationFailureHasRunIDJournalAndReceipt(t *testing.T) { } } -func TestRustRunWithoutLockfileFailsDuringInitialization(t *testing.T) { - skillDir := t.TempDir() - manifest := `{"version":1,"yield_version":"1.0.0","language":"rust","run":["cargo","run"]}` - if err := os.WriteFile(filepath.Join(skillDir, "skill.json"), []byte(manifest), 0o600); err != nil { +func TestRustWorkspaceSkillWithoutLocalLockfileReachesExecution(t *testing.T) { + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "Cargo.lock"), []byte("version = 4\n"), 0o600); err != nil { + t.Fatal(err) + } + skillDir := filepath.Join(workspace, "audit-security") + if err := os.MkdirAll(skillDir, 0o700); err != nil { t.Fatal(err) } + files := map[string]string{ + "main.rs": "fn main() {}\n", + "runner.go": `package main +import "fmt" +func main() { fmt.Println("{\"type\":\"terminal\",\"terminal\":{\"status\":\"completed\",\"result\":null}}") } +`, + "skill.json": `{"version":1,"yield_version":"1.0.0","language":"rust","run":["go","run","runner.go"]}`, + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(skillDir, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + if _, err := os.Stat(filepath.Join(skillDir, "Cargo.lock")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("test skill unexpectedly has a local lockfile: %v", err) + } runsDir := filepath.Join(t.TempDir(), "runs") if err := os.MkdirAll(runsDir, 0o700); err != nil { t.Fatal(err) } e := &Engine{SkillDir: skillDir, RunsDir: runsDir, SupervisorVersion: "1.0.0", Stderr: os.Stderr} - _, err := e.StartRun(nil) - var runErr *RunError - if !errors.As(err, &runErr) { - t.Fatalf("expected run-bound initialization error, got %v", err) + progress, err := e.StartRun(nil) + if err != nil { + t.Fatal(err) + } + if progress.Terminal == nil || progress.Terminal.Status != protocol.StatusCompleted { + t.Fatalf("workspace Rust skill did not reach completion: %+v", progress) } - r, _, loadErr := receipt.StoreForRunsDir(runsDir).LoadRun(runErr.RunID) + r, _, loadErr := receipt.StoreForRunsDir(runsDir).LoadRun(progress.RunID) if loadErr != nil { t.Fatal(loadErr) } - if r.Outcome.FailureCode != "source_lockfile_missing" { - t.Fatalf("failure code = %q", r.Outcome.FailureCode) + if r.Outcome.Phase != "terminal" || r.Skill.SourceDigest == nil || r.Skill.SourceDigest.Profile != protocol.SkillSourceProfileV1 { + t.Fatalf("unexpected receipt: %+v", r) } } diff --git a/internal/receipt/receipt.go b/internal/receipt/receipt.go index dad1595..f384a18 100644 --- a/internal/receipt/receipt.go +++ b/internal/receipt/receipt.go @@ -689,7 +689,7 @@ func (receipt *RunReceipt) Validate() error { } validFailureCode := map[string]bool{ "manifest_invalid": true, "manifest_read_failed": true, "runtime_version_missing": true, - "runtime_incompatible": true, "runner_missing": true, "source_lockfile_missing": true, + "runtime_incompatible": true, "runner_missing": true, "source_digest_failed": true, "initialization_failed": true, "invalid_program_output": true, "execution_timeout": true, "subprocess_failed": true, "execution_failed": true, "command_execution_failed": true, diff --git a/ir/yield.observation.v1/run-receipt.schema.json b/ir/yield.observation.v1/run-receipt.schema.json index 68d7882..995ad33 100644 --- a/ir/yield.observation.v1/run-receipt.schema.json +++ b/ir/yield.observation.v1/run-receipt.schema.json @@ -164,7 +164,6 @@ "runtime_version_missing", "runtime_incompatible", "runner_missing", - "source_lockfile_missing", "source_digest_failed", "initialization_failed", "invalid_program_output", From fcbfb7431557de291f80f1c12d816c789c09e161 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 20 Aug 2026 09:31:24 +0100 Subject: [PATCH 3/3] Clarify Rust source digest boundary --- docs/reference/run-receipts.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/reference/run-receipts.md b/docs/reference/run-receipts.md index ffa6ea1..5fdea54 100644 --- a/docs/reference/run-receipts.md +++ b/docs/reference/run-receipts.md @@ -52,9 +52,10 @@ an atomic installation, and a synced reference update. A crash between object creation and reference update is repaired by materializing the same journal again. Garbage collection is not part of this release. -Rust workflows require `Cargo.lock` before a run starts so `cargo run` cannot -create a new source fact after the run is bound. Yield's Rust scaffolds and -developer-helper installer generate this lockfile. +Rust source profiles include `Cargo.toml` and `Cargo.lock` when those files are +inside the skill directory. Workspace-managed Rust skills without a local +lockfile remain valid; parent workspace files are outside the skill's recorded +source-digest boundary. Run age is query-relative. `yskill report` can say that an open run is older than a supplied threshold, but it does not declare the run abandoned.