Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ROLE_RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
46 changes: 46 additions & 0 deletions cmd/relay/erasure_timestamp_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 5 additions & 1 deletion cmd/relay/roles_more.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
43 changes: 40 additions & 3 deletions cmd/relay/workflow_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"bufio"
"encoding/json"
"errors"
"flag"
"fmt"
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions scripts/three-machine-rehearsal/04-role-participate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down