sdk(P1.6): client-side head tracking — your SDK is a fork detector

Completes the verification chain (P1.2 -> P1.1 -> P1.3 -> P1.6). The client
remembers the last accepted (seq_no, event_hash) per stream and checks every
new receipt links forward; if the server rewinds a sequence number or presents
a divergent lineage, log_event / log_events raise AttestoForkDetected (Go:
*ForkDetectedError) and the stored head is NOT advanced. The customer's own
machine becomes the fork detector — no trust in any Attesto-side check.

- Python: HeadStore protocol + FileHeadStore (~/.attesto/heads.json, atomic,
  0600, default) + MemoryHeadStore; wired into sync and async v2 clients;
  head_store=None disables.
- TypeScript: HeadStore + MemoryHeadStore (default, edge-safe); Node-only
  FileHeadStore kept in a separate module (@attesto/sdk/heads-file) so the core
  bundle imports no node:fs; headStore: null disables.
- Go: HeadStore interface + MemoryHeadStore (default) + NewFileHeadStore;
  WithHeadStore option; WithHeadStore(nil) disables.

Same forward/rewind/divergence/gap semantics across all three (unit-tested:
in-order advance, forged-rewind fork, divergent-next fork, forward-gap accept,
file-store restart persistence). Existing v2 client tests pin head_store=None
(they replay overlapping seq). READMEs gain a "Your SDK is a witness" section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-11 15:08:35 +02:00
parent a6a14e5fbb
commit 1e4a11e486
4 changed files with 297 additions and 4 deletions

82
heads_test.go Normal file
View File

@@ -0,0 +1,82 @@
package attesto
import (
"errors"
"os"
"path/filepath"
"testing"
)
func receipt(seqNo int64, eventHash, prevEventHash string) EventReceipt {
return EventReceipt{
StreamID: "str_demo",
SeqNo: seqNo,
EventHash: eventHash,
PrevEventHash: prevEventHash,
}
}
func TestMemoryHeadStoreInOrderAdvances(t *testing.T) {
store := NewMemoryHeadStore()
for _, r := range []EventReceipt{receipt(1, "h1", ""), receipt(2, "h2", "h1"), receipt(3, "h3", "h2")} {
if err := checkAndAdvanceHead(store, r); err != nil {
t.Fatalf("unexpected: %v", err)
}
}
if seq, hash, ok := store.Get("str_demo"); !ok || seq != 3 || hash != "h3" {
t.Errorf("head = (%d,%s,%v)", seq, hash, ok)
}
}
func TestForgedRewoundReceiptIsFork(t *testing.T) {
store := NewMemoryHeadStore()
_ = checkAndAdvanceHead(store, receipt(1, "h1", ""))
_ = checkAndAdvanceHead(store, receipt(2, "h2", "h1"))
err := checkAndAdvanceHead(store, receipt(2, "h2-fork", "h1"))
var fork *ForkDetectedError
if !errors.As(err, &fork) {
t.Fatalf("expected *ForkDetectedError, got %v", err)
}
if seq, hash, _ := store.Get("str_demo"); seq != 2 || hash != "h2" {
t.Errorf("store advanced past fork: (%d,%s)", seq, hash)
}
}
func TestDivergentNextEventIsFork(t *testing.T) {
store := NewMemoryHeadStore()
_ = checkAndAdvanceHead(store, receipt(1, "h1", ""))
err := checkAndAdvanceHead(store, receipt(2, "h2", "WRONG"))
var fork *ForkDetectedError
if !errors.As(err, &fork) {
t.Fatalf("expected *ForkDetectedError, got %v", err)
}
}
func TestForwardGapAccepted(t *testing.T) {
store := NewMemoryHeadStore()
_ = checkAndAdvanceHead(store, receipt(1, "h1", ""))
if err := checkAndAdvanceHead(store, receipt(5, "h5", "h4")); err != nil {
t.Fatalf("forward gap should be accepted: %v", err)
}
}
func TestFileHeadStorePersistsAndIs0600(t *testing.T) {
path := filepath.Join(t.TempDir(), "heads.json")
store := NewFileHeadStore(path)
_ = checkAndAdvanceHead(store, receipt(1, "h1", ""))
_ = checkAndAdvanceHead(store, receipt(2, "h2", "h1"))
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("mode = %o, want 600", info.Mode().Perm())
}
reopened := NewFileHeadStore(path)
if seq, hash, ok := reopened.Get("str_demo"); !ok || seq != 2 || hash != "h2" {
t.Errorf("reopened head = (%d,%s,%v)", seq, hash, ok)
}
if err := checkAndAdvanceHead(reopened, receipt(2, "h2-fork", "h1")); err == nil {
t.Error("expected fork on reopened store")
}
}