diff --git a/ROLE_RUNBOOK.md b/ROLE_RUNBOOK.md index b9330f3..a362518 100644 --- a/ROLE_RUNBOOK.md +++ b/ROLE_RUNBOOK.md @@ -129,7 +129,9 @@ Wait for a published closure: relay witness run --config "$ROLE_CONFIG" --interval 60s -Use `--once` to poll once and exit. After observing closure, independently +Use `--once` to poll once and exit; it exits non-zero when no closure has +been published yet, so a single check that observed nothing is never mistaken +for an observation. After observing closure, independently confirm that its beacon round has not occurred and is at least the definition's witness lead away. Relay cannot make that real-world timing claim for you. diff --git a/cmd/relay/erasure_timestamp_test.go b/cmd/relay/erasure_timestamp_test.go new file mode 100644 index 0000000..d93f76c --- /dev/null +++ b/cmd/relay/erasure_timestamp_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func writeAttestation(t *testing.T, contributedAt string) string { + t.Helper() + dir := t.TempDir() + body := `{"schema":"x","contributed_at":"` + contributedAt + `"}` + if err := os.WriteFile(filepath.Join(dir, "attestation.json"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return dir +} + +func TestErasureTimestampStrictlyAfterContribution(t *testing.T) { + contributed := time.Date(2026, 8, 20, 15, 25, 51, 0, time.UTC) + + // Normal case: hours of compute already separate the two stamps. + later := contributed.Add(time.Hour) + dir := writeAttestation(t, contributed.Format(time.RFC3339)) + if got := erasureTimestamp(dir, later); !got.Equal(later) { + t.Fatalf("later timestamp rewritten to %v", got) + } + + // Same-second confirmation: the tiny circuit contributes in under a + // second, so destroyed_at must be pushed to the next whole second. + sameSecond := contributed.Add(400 * time.Millisecond) + got := erasureTimestamp(dir, sameSecond) + if !got.Truncate(time.Second).After(contributed.Truncate(time.Second)) { + t.Fatalf("destroyed_at %v is not strictly after contributed_at %v", got, contributed) + } + + // Unreadable metadata must not block erasure: fall back to now. + if got := erasureTimestamp(t.TempDir(), later); !got.Equal(later) { + t.Fatalf("missing attestation changed timestamp to %v", got) + } + badDir := writeAttestation(t, "not-a-timestamp") + if got := erasureTimestamp(badDir, later); !got.Equal(later) { + t.Fatalf("unparseable attestation changed timestamp to %v", got) + } +} diff --git a/cmd/relay/roles_more.go b/cmd/relay/roles_more.go index fb72cfb..fe4c07c 100644 --- a/cmd/relay/roles_more.go +++ b/cmd/relay/roles_more.go @@ -67,7 +67,11 @@ func runWatch(args []string) error { } fmt.Printf("%s open, %d accepted, waiting on %s\n", o.phase, pos.accepted, pos.nextID) if once { - return nil + // A single check that observed nothing must not look like a + // successful observation: the caller's next step is preparing a + // signed witness receipt, and scripts gate their "saw the + // closure" reporting on this exit status. + return fmt.Errorf("no closure observed: %s is still open", o.phase) } time.Sleep(interval) } diff --git a/cmd/relay/workflow_commands.go b/cmd/relay/workflow_commands.go index 4d1cbd3..83b68a5 100644 --- a/cmd/relay/workflow_commands.go +++ b/cmd/relay/workflow_commands.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "encoding/json" "errors" "flag" "fmt" @@ -288,8 +289,12 @@ func fetchForContribution(o roleOpts, pos position) error { } func confirmErasure() error { - fmt.Fprintln(os.Stderr, "Contribution complete. Destroy the contribution environment now.") - fmt.Fprint(os.Stderr, "After it is destroyed, type DESTROYED and press Enter: ") + // The prompt goes to stdout so that a logged or tee'd transcript of the + // run contains it: operators and automation watch that transcript, and a + // prompt that only ever reaches the terminal's stderr is invisible to + // both after the fact. + fmt.Println("Contribution complete. Destroy the contribution environment now.") + fmt.Print("After it is destroyed, type DESTROYED and press Enter: ") line, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil && len(line) == 0 { return err @@ -300,8 +305,40 @@ func confirmErasure() error { return nil } +// erasureTimestamp returns a destroyed_at that proof-tool will accept: +// strictly after the candidate's contributed_at at whole-second resolution. +// Timestamps are stamped in whole seconds, so a contribution that completes +// and is confirmed within the same second would otherwise be rejected with +// "destroyed_at must be strictly after contributed_at". Waiting out the +// remainder of that second preserves the strict ordering rule instead of +// weakening it. +func erasureTimestamp(candidateDir string, now time.Time) time.Time { + raw, err := os.ReadFile(filepath.Join(candidateDir, "attestation.json")) + if err != nil { + return now + } + var attestation struct { + ContributedAt string `json:"contributed_at"` + } + if json.Unmarshal(raw, &attestation) != nil { + return now + } + contributed, err := time.Parse(time.RFC3339, attestation.ContributedAt) + if err != nil { + return now + } + if !now.Truncate(time.Second).After(contributed.Truncate(time.Second)) { + wait := contributed.Truncate(time.Second).Add(time.Second).Sub(now) + if wait > 0 && wait <= 2*time.Second { + time.Sleep(wait) + } + return contributed.Truncate(time.Second).Add(time.Second) + } + return now +} + func runErasure(o roleOpts) error { - return runErasureAt(o, time.Now()) + return runErasureAt(o, erasureTimestamp(o.outDir, time.Now().UTC())) } func runErasureAt(o roleOpts, destroyedAt time.Time) error { diff --git a/scripts/three-machine-rehearsal/04-role-participate.sh b/scripts/three-machine-rehearsal/04-role-participate.sh index 8f48000..250a2fd 100755 --- a/scripts/three-machine-rehearsal/04-role-participate.sh +++ b/scripts/three-machine-rehearsal/04-role-participate.sh @@ -31,6 +31,29 @@ require_fresh_path "$manifest_key_file" mkdir -p "$participant_root" "$candidate_parent" chmod 0700 "$participant_root" "$candidate_parent" +# A failed turn must stay recoverable without knowledge of the run-root +# layout: archive this attempt's outputs so the fresh-path checks above pass +# on retry, and nothing is deleted. The archive keeps the local candidate; +# nothing in it has been published. +archive_failed_attempt() { + local status=$? + [[ $status -eq 0 ]] && return 0 + local archive + archive="$RUN_ROOT/failed/$phase-$participant_id-$(date -u +%Y%m%dT%H%M%SZ)" + mkdir -p "$archive" + chmod 0700 "$RUN_ROOT/failed" "$archive" + local path + for path in "$participant_config" "$participate_log" "$manifest_key_file" \ + "$candidate_parent" "$participant_root"; do + [[ -e "$path" ]] && mv "$path" "$archive/" 2>/dev/null + done + printf '\nAttempt failed (exit %d); its outputs were archived to:\n%s\n' \ + "$status" "$archive" >&2 + printf 'Rerun this script to retry. Nothing was deleted.\n' >&2 + return "$status" +} +trap archive_failed_attempt EXIT + "$RELAY_BIN" enroll \ --storage "$STORAGE_CONFIG" \ --grant "$grant" \