diff --git a/COORDINATOR_RUNBOOK.md b/COORDINATOR_RUNBOOK.md index 525c2c9..274108f 100644 --- a/COORDINATOR_RUNBOOK.md +++ b/COORDINATOR_RUNBOOK.md @@ -161,6 +161,13 @@ head. It downloads into a fresh review directory and invokes the new head. Inbox submissions remain available for review or provider lifecycle cleanup. +By default Relay records `accepted_at` from the coordinator clock after the +candidate download, preserving subsecond precision so it is strictly later +than the participant's signed `destroyed_at`. Keep participant and coordinator +clocks synchronized. Use `--accepted-at RFC3339` only for a controlled replay; +the explicit value must still be strictly later than the signed erasure and +the preceding accepted record. + Repeat for every participant and phase. ## 5. Publish lifecycle changes diff --git a/cmd/relay/coordinator_workflow.go b/cmd/relay/coordinator_workflow.go index 9706ef6..56cb5af 100644 --- a/cmd/relay/coordinator_workflow.go +++ b/cmd/relay/coordinator_workflow.go @@ -71,7 +71,7 @@ func runAcceptCandidate(args []string) error { set.StringVar(&root, "root", "", "coordinator transcript root") set.StringVar(&candidateDir, "candidate-dir", "", "fresh local directory for the downloaded candidate") set.StringVar(&coordinatorSigningKey, "coordinator-signing-key", "", "coordinator Ed25519 private key") - set.StringVar(&acceptedAt, "accepted-at", "", "acceptance timestamp (defaults to now)") + set.StringVar(&acceptedAt, "accepted-at", "", "acceptance timestamp (defaults to the current time after candidate download)") set.StringVar(&phase1Seal, "phase1-seal", "", "phase 2: sealed phase-1 record") set.StringVar(&phase1SealSignature, "phase1-seal-signature", "", "phase 2: phase-1 seal signature") set.BoolVar(&verify, "verify-publish", false, "confirm every published object after advancing the head") @@ -81,11 +81,10 @@ func runAcceptCandidate(args []string) error { if storagePath == "" || candidateKey == "" || root == "" || candidateDir == "" || coordinatorSigningKey == "" { return errors.New("--storage, --candidate-key, --root, --candidate-dir and --coordinator-signing-key are required") } - if acceptedAt == "" { - acceptedAt = time.Now().UTC().Format(time.RFC3339) - } - if _, err := time.Parse(time.RFC3339, acceptedAt); err != nil { - return errors.New("--accepted-at must be RFC3339") + if acceptedAt != "" { + if _, err := time.Parse(time.RFC3339, acceptedAt); err != nil { + return errors.New("--accepted-at must be RFC3339") + } } if !safeObjectKey(candidateKey) || !strings.HasSuffix(candidateKey, "/manifest.json") { return errors.New("--candidate-key must be a safe manifest object key") @@ -161,6 +160,9 @@ func runAcceptCandidate(args []string) error { }); err != nil { return err } + if acceptedAt == "" { + acceptedAt = defaultAcceptanceTimestamp(time.Now()) + } command := []string{manifest.Phase, "verify", "--ceremony", config.CeremonyPath, "--ceremony-signature", config.CeremonySignature, "--coordinator-public-key-file", config.CoordinatorPublicKey, "--transcript-dir", root, "--chain", pos.chainPath, "--chain-signature", pos.chain.ChainSignaturePath, @@ -198,6 +200,10 @@ func runAcceptCandidate(args []string) error { return nil } +func defaultAcceptanceTimestamp(now time.Time) string { + return now.UTC().Format(time.RFC3339Nano) +} + func runEvidenceInbox(args []string) error { set := flag.NewFlagSet("coordinator evidence", flag.ContinueOnError) var storagePath, role string diff --git a/cmd/relay/coordinator_workflow_test.go b/cmd/relay/coordinator_workflow_test.go new file mode 100644 index 0000000..d39b37a --- /dev/null +++ b/cmd/relay/coordinator_workflow_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "testing" + "time" +) + +func TestDefaultAcceptanceTimestampPreservesSubsecondOrdering(t *testing.T) { + destroyedAt := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC) + acceptedAt := destroyedAt.Add(time.Nanosecond) + + encoded := defaultAcceptanceTimestamp(acceptedAt) + decoded, err := time.Parse(time.RFC3339Nano, encoded) + if err != nil { + t.Fatal(err) + } + if !decoded.After(destroyedAt) { + t.Fatalf("accepted_at %q is not strictly after destroyed_at %q", encoded, destroyedAt.Format(time.RFC3339Nano)) + } + if !decoded.Equal(acceptedAt) { + t.Fatalf("accepted_at = %q, want %q", encoded, acceptedAt.Format(time.RFC3339Nano)) + } +} diff --git a/cmd/relay/proof_tool_integration_test.go b/cmd/relay/proof_tool_integration_test.go index 91c24e1..0ded352 100644 --- a/cmd/relay/proof_tool_integration_test.go +++ b/cmd/relay/proof_tool_integration_test.go @@ -20,6 +20,7 @@ import ( // // Run the fast compatibility checks with: // +// (cd /path/to/proof-tool && bash scripts/bootstrap-vendor.sh) // RELAY_PROOF_TOOL_DIR=/path/to/proof-tool go test ./cmd/relay \ // -run TestProofToolCompatibility -v // @@ -274,7 +275,8 @@ func testProofToolContributionCommands(t *testing.T, ceremonyBinary, fixtureRoot "--coordinator-public-key-file", inspector.CoordinatorPublicKeyPath, "--transcript-dir", ceremonyRoot, "--chain", chainPath, "--chain-signature", chainSignature, "--candidate-dir", candidateDir, - "--coordinator-signing-key", coordinatorSigningKey, "--accepted-at", "2026-08-18T12:02:00Z") + "--coordinator-signing-key", coordinatorSigningKey, + "--accepted-at", defaultAcceptanceTimestamp(time.Now().Add(time.Second))) accepted, err := inspector.Chain( filepath.Join(ceremonyRoot, "phase1", "chain-0001.json"), filepath.Join(ceremonyRoot, "phase1", "chain-0001.sig"), @@ -300,7 +302,7 @@ func writeTestJSON(t *testing.T, path string, value any) { func buildProofProgram(t *testing.T, proofToolDir, output, packagePath string) { t.Helper() - command := exec.Command("go", "build", "-mod=mod", "-o", output, packagePath) + command := exec.Command("go", "build", "-mod=vendor", "-o", output, packagePath) command.Dir = proofToolDir if combined, err := command.CombinedOutput(); err != nil { t.Fatalf("build proof-tool program %s: %v\n%s", packagePath, err, combined) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index d71ecfa..1e20d70 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -209,12 +209,25 @@ exact clean checkout can build the CLI directly: checkout --detach "$PROOF_TOOL_COMMIT" ( cd "$RELEASE_EVIDENCE_ROOT/proof-tool-source" - CGO_ENABLED=0 go build -trimpath -buildvcs=true \ + bash scripts/bootstrap-vendor.sh + CGO_ENABLED=0 go build -mod=vendor -trimpath -buildvcs=true \ -o "$RELEASE_EVIDENCE_ROOT/mpc-ceremony" ./cmd/mpc-ceremony ) Never use `go run` for `mpc-ceremony`; the program requires VCS metadata that -`go run` omits. +`go run` omits. Do not replace the vendored build above with `-mod=mod`: +proof-tool's reviewed gnark changes are applied by `bootstrap-vendor.sh` and +must be present in the tested binary. + +Before approving a coordinated release, exercise Relay against that exact +proof-tool checkout, including the production-sized contribution and +acceptance path: + + cd /path/to/relay + RELAY_PROOF_TOOL_DIR="$RELEASE_EVIDENCE_ROOT/proof-tool-source" \ + RELAY_PROOF_TOOL_FULL=1 \ + go test ./cmd/relay -run '^TestProofToolCompatibility$' -count=1 \ + -timeout 30m -v For a production proof-tool release, follow its repository's approved signed tag and reproducible-release procedure using