From 1d2065ada30a02e55c507897e6d9f7b334882577 Mon Sep 17 00:00:00 2001 From: Jodson Graves Date: Sun, 12 Jul 2026 21:39:23 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(record):=20Merkle=20proof=20shape=20?= =?UTF-8?q?=E2=80=94=20RFC-6962=20tree=20replaces=20the=20linear=20fold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executes the Merkle-now decision (Jodson, 2026-07-12), the stack plan's highest-rework item, inside the only cheap window it will ever have: no durable logs, no external consumers, no live witnesses. - Head is now the RFC-6962 Merkle tree head over leaf IDs (leaves keep drops/leaf/v0; interior nodes carry drops/node/v1 — the 0x00/0x01 discipline in canon-domain form, so leaves never replay as nodes). - Inclusion proofs are audit paths: ~log2(size) hashes however large the log grows. This is the open-problem-8 floor made real — an entry-level phone verifies its covenant in a million-entry log with ~20 hashes. - Consistency proofs are RFC-6962 subproofs; Log.ProveConsistency serves the witness's extension evidence, and Witness.Countersign consumes the proof instead of raw leaves. - LogID moves to its own drops/logid/v1 tag (the chain-fold arity is gone); checkpoint and witness payloads bump to v1. Entry/leaf/content stay v0 — byte-identical derivations keep their tags. - Exhaustive property suite: every leaf of every tree size to 33, every consistency pair to 33, tamper/truncation/padding/replay rejection, and the Log-level tie-in over a real appended log. The un-checkpointed-order residual is unchanged (exactly CT's), and the witness amnesia residual is unchanged; neither is papered over. Signed-off-by: Jodson Graves --- internal/record/checkpoint.go | 8 +- internal/record/doc.go | 46 +++++----- internal/record/entry.go | 12 ++- internal/record/log.go | 73 ++++++++------- internal/record/log_test.go | 79 ++++++++-------- internal/record/tree.go | 150 ++++++++++++++++++++++++++++++ internal/record/tree_test.go | 158 ++++++++++++++++++++++++++++++++ internal/record/verify.go | 62 +++++++------ internal/record/witness.go | 15 +-- internal/record/witness_test.go | 6 +- 10 files changed, 472 insertions(+), 137 deletions(-) create mode 100644 internal/record/tree.go create mode 100644 internal/record/tree_test.go diff --git a/internal/record/checkpoint.go b/internal/record/checkpoint.go index 94c9d0f..569b484 100644 --- a/internal/record/checkpoint.go +++ b/internal/record/checkpoint.go @@ -10,14 +10,16 @@ import ( // domainCheckpoint tags checkpoint signing payloads; an operator's // checkpoint signature is never valid under any other tag, so it can never // pose as a member seal or a witness countersignature. -const domainCheckpoint = "drops/checkpoint/v0" +const domainCheckpoint = "drops/checkpoint/v1" // Checkpoint is an operator's signed, monotonic commitment to its log head — -// the Certificate Transparency signed-tree-head analogue for a linear chain. +// the Certificate Transparency signed tree head. v1: Head is the RFC-6962 +// Merkle tree head over the leaf IDs (the v0 linear fold is gone, decided +// 2026-07-12 before any durable log existed). type Checkpoint struct { Log Hash // LogID of the operator's log Size uint64 // number of entries committed - Head Hash // chain hash after entry Size-1 (fold seeded with Log); the LogID seed when Size == 0 + Head Hash // Merkle tree head over leaves [0, Size); the LogID seed when Size == 0 IssuedAt time.Time // UTC Signature []byte // ed25519 by the operator; excluded from CanonicalBytes } diff --git a/internal/record/doc.go b/internal/record/doc.go index 7b11fd8..b144143 100644 --- a/internal/record/doc.go +++ b/internal/record/doc.go @@ -1,7 +1,7 @@ // Package record is Drops — Cloudy's dialog-sealed, append-only, witnessed // record: the durable account of what two members agreed to, kept as -// per-operator hash-chained logs whose integrity any member can verify -// offline. Drops is the product name (and the domain-tag namespace, drops/*); +// per-operator Merkle-tree logs (RFC 6962 shape) whose integrity any member +// can verify offline with logarithmic-size proofs. Drops is the product name (and the domain-tag namespace, drops/*); // record stays the Go package name per the 2026-07-09 naming decision. It is a // JFA member-economy layer the substrate coordination protocol does not // define, and the member-facing analogue of the protocol's anchor/ package: @@ -55,21 +55,21 @@ // is an ordinary new Entry whose Corrects field references the entry it // corrects while removing nothing. Runtime second: OpenLog replays and // cryptographically re-verifies the entire store (seals, log binding, -// corrections, duplicates, chain fold), rejecting invalid, foreign, -// duplicate, and dangling-correction entries — but the ORDER of independent -// un-checkpointed entries is NOT fixed by replay alone: a store with two -// such entries swapped replays cleanly to a different head. That order is -// protected by the next rung, and the gap is exactly CT's residual. Append -// rejects a nonzero Corrects that names no in-log entry. Detectable third: -// operator-signed monotonic Checkpoints plus +// corrections, duplicates) and rebuilds the Merkle tree, rejecting invalid, +// foreign, duplicate, and dangling-correction entries — but the ORDER of +// independent un-checkpointed entries is NOT fixed by replay alone: a store +// with two such entries swapped replays cleanly to a different head. That +// order is protected by the next rung, and the gap is exactly CT's residual. +// Append rejects a nonzero Corrects that names no in-log entry. Detectable +// third: operator-signed monotonic Checkpoints plus // VerifyConsistency make any rewrite of checkpointed history // cryptographically visible to anyone holding an older checkpoint; the // checkpoint pair is itself portable fork evidence. // // The record MUST NOT be a single global ledger over unrelated exchanges. // Structural: a Log is constructed from exactly one operator key, and its -// derived identity LogID(operator) seeds the chain fold, so every position -// is scoped to one log. Each Entry carries its LogID INSIDE the dual-signed +// derived identity LogID(operator) is the empty log's head and the scope of +// every checkpoint, so every position is scoped to one log. Each Entry carries its LogID INSIDE the dual-signed // bytes, so a sealed covenant can never migrate or be replayed into another // operator's log, and Checkpoint.Verify binds cp.Log == LogID(operator) so // commitments cannot be replayed either. The package exports no merge, no @@ -110,27 +110,29 @@ // agreements distinct and makes double-appends detectable. // // Signing bytes are canonical, domain-separated, and UTC. All signing and -// hashing payloads are built with the protocol's canon package under the six -// distinct tags below; canon.Time normalizes every signed instant to UTC -// nanoseconds and drops monotonic and location components. +// hashing payloads are built with the protocol's canon package under the +// seven distinct tags below; canon.Time normalizes every signed instant to +// UTC nanoseconds and drops monotonic and location components. // // # Domain tags // // Every hash and signature in the package is computed over canonical bytes -// beginning with one of six distinct, unexported domain tags: +// beginning with one of seven distinct, unexported domain tags: // // drops/content/v0 HashContent digests // drops/entry/v0 Entry signing payloads (both seals) // drops/leaf/v0 Entry.ID leaf hashes -// drops/chain/v0 the chain derivation: LogID seed and fold steps -// drops/checkpoint/v0 Checkpoint signing payloads -// drops/witness/v0 witness countersignature payloads +// drops/logid/v1 LogID derivation (the empty log's head) +// drops/node/v1 interior Merkle tree nodes +// drops/checkpoint/v1 Checkpoint signing payloads +// drops/witness/v1 witness countersignature payloads // // so no artifact is transferable between message types, between a hash role -// and a signature role, or back into sohocloud-protocol messages. The chain -// tag covers the two arities of the one chain derivation — the single-field -// seed (LogID over the operator key) and the two-field fold step — which -// canon's length prefixes keep disjoint. +// and a signature role, or back into sohocloud-protocol messages. Leaves and +// interior nodes carry different tags — the RFC 6962 0x00/0x01 discipline in +// canon form — so a leaf can never be replayed as a node or vice versa. The +// v1 tags mark the 2026-07-12 Merkle decision (proof shape settled before +// any durable log existed); the v0 chain tag is retired, not reused. // // # Named residuals // diff --git a/internal/record/entry.go b/internal/record/entry.go index 2bb4944..49ad5fd 100644 --- a/internal/record/entry.go +++ b/internal/record/entry.go @@ -39,11 +39,17 @@ func HashContent(content []byte) Hash { return Hash(sha256.Sum256(canon.New(domainContent).Bytes(content).Sum())) } +// domainLogID tags the LogID derivation — a log's identity, derived from +// its operator key, never chosen text; it is the empty log's head and scopes +// every entry and checkpoint. v1: the v0 chain-fold arity is gone (the tree +// replaced it), so the derivation carries its own single-purpose tag. +const domainLogID = "drops/logid/v1" + // LogID derives a log's identity from its operator's public key — derived, -// never chosen, so it cannot carry text and cannot be squatted; it seeds the -// log's hash chain and scopes every entry and checkpoint. +// never chosen, so it cannot carry text and cannot be squatted; it is the +// head of the empty log and scopes every entry and checkpoint. func LogID(operator ed25519.PublicKey) Hash { - return Hash(sha256.Sum256(canon.New(domainChain).Bytes(operator).Sum())) + return Hash(sha256.Sum256(canon.New(domainLogID).Bytes(operator).Sum())) } // Entry is one dialog-sealed covenant between two members: fixed-size diff --git a/internal/record/log.go b/internal/record/log.go index 482fd04..d2d1bc1 100644 --- a/internal/record/log.go +++ b/internal/record/log.go @@ -2,25 +2,11 @@ package record import ( "crypto/ed25519" - "crypto/sha256" "errors" "fmt" "time" - - "github.com/NTARI-RAND/sohocloud-protocol/canon" ) -// domainChain tags the one chain derivation in both its arities: the -// single-field seed (LogID over the operator key) and the two-field fold -// step (previous head, leaf). Canon's length prefixes keep the two shapes -// disjoint, and neither is a signature payload. -const domainChain = "drops/chain/v0" - -// chainStep folds one leaf hash into the running chain head. -func chainStep(prev, leaf Hash) Hash { - return Hash(sha256.Sum256(canon.New(domainChain).Bytes(prev[:]).Bytes(leaf[:]).Sum())) -} - // Store is the minimal append-only persistence surface: update and delete // are absent verbs, not forbidden ones. Log never trusts a Store — chains // are re-verified on open. @@ -73,16 +59,16 @@ func (s *MemStore) Len() (uint64, error) { return uint64(len(s.entries)), nil } -// Log is ONE operator's record: a LogID-seeded hash chain over dialog-sealed -// entries. No package-level log, no merge, no cross-log query or ordering -// exists anywhere in the surface. The operator's entire power is assigning -// sequence numbers: it holds no key that can produce a member seal, so it -// can order covenants but never author, amend, or remove one. +// Log is ONE operator's record: an RFC-6962-shaped Merkle tree over +// dialog-sealed entries, identified by LogID. No package-level log, no +// merge, no cross-log query or ordering exists anywhere in the surface. The +// operator's entire power is assigning sequence numbers: it holds no key +// that can produce a member seal, so it can order covenants but never +// author, amend, or remove one. type Log struct { id Hash store Store - heads []Hash // heads[i] is the chain head before entry i; heads[len(leaves)] is the current head - leafs []Hash // leaf IDs in sequence order + leafs []Hash // leaf IDs (Entry.ID) in sequence order — the tree's leaves index map[Hash]uint64 // leaf ID -> sequence, for dedup and Corrects resolution } @@ -99,11 +85,9 @@ func OpenLog(operator ed25519.PublicKey, s Store) (*Log, error) { if s == nil { return nil, errors.New("record: nil store") } - id := LogID(operator) l := &Log{ - id: id, + id: LogID(operator), store: s, - heads: []Hash{id}, index: make(map[Hash]uint64), } n, err := s.Len() @@ -145,12 +129,10 @@ func (l *Log) check(e Entry) (Hash, error) { return leaf, nil } -// admit folds an already-checked leaf into the in-memory chain state. +// admit adds an already-checked leaf to the in-memory tree state. func (l *Log) admit(leaf Hash) { - seq := uint64(len(l.leafs)) - l.index[leaf] = seq + l.index[leaf] = uint64(len(l.leafs)) l.leafs = append(l.leafs, leaf) - l.heads = append(l.heads, chainStep(l.heads[seq], leaf)) } // Append admits e only if e.Verify() passes, e.Log equals this log's LogID, @@ -170,33 +152,56 @@ func (l *Log) Append(e Entry) (uint64, error) { return seq, nil } -// Checkpoint returns the unsigned checkpoint over the current head at UTC -// instant at; the operator seals it with Checkpoint.Sign. Sizes are +// Checkpoint returns the unsigned checkpoint over the current tree head at +// UTC instant at; the operator seals it with Checkpoint.Sign. Sizes are // monotonic because the log only appends. The head of an empty log is the -// LogID seed. +// LogID seed; otherwise it is the Merkle tree head over the leaf IDs. func (l *Log) Checkpoint(at time.Time) Checkpoint { size := uint64(len(l.leafs)) + head := l.id + if size > 0 { + head = mth(l.leafs) + } return Checkpoint{ Log: l.id, Size: size, - Head: l.heads[size], + Head: head, IssuedAt: at, } } // Prove returns the inclusion proof for the entry at seq relative to the // current head; issue the matching Checkpoint in the same quiescent state. +// The proof is an RFC-6962 audit path: ~log2(size) sibling hashes, small +// enough to hold and verify on an entry-level device regardless of how +// large the log grows. func (l *Log) Prove(seq uint64) (Proof, error) { size := uint64(len(l.leafs)) if seq >= size { return Proof{}, fmt.Errorf("record: sequence %d out of range (size %d)", seq, size) } return Proof{ - Prior: l.heads[seq], - Links: append([]Hash(nil), l.leafs[seq+1:]...), + Seq: seq, + Path: provePath(seq, l.leafs), }, nil } +// ProveConsistency returns the RFC-6962 consistency proof showing the +// current tree extends the tree that had `size` entries — the extension +// evidence a witness needs before countersigning a newer checkpoint. A size +// of zero needs no proof (everything extends the empty log), and the +// current size yields the empty proof (a tree extends itself). +func (l *Log) ProveConsistency(size uint64) ([]Hash, error) { + current := uint64(len(l.leafs)) + if size > current { + return nil, fmt.Errorf("record: size %d exceeds log size %d", size, current) + } + if size == 0 || size == current { + return []Hash{}, nil + } + return proveConsistency(size, l.leafs), nil +} + // LeavesSince returns the leaf hashes of entries at positions // [size, current size) — the extension evidence a witness or member needs // for VerifyConsistency. diff --git a/internal/record/log_test.go b/internal/record/log_test.go index 062f7e1..168c0ca 100644 --- a/internal/record/log_test.go +++ b/internal/record/log_test.go @@ -302,8 +302,8 @@ func TestInclusionExhaustive(t *testing.T) { if !VerifyInclusion(entries[seq], p, tc.cp, op.pub) { t.Fatalf("inclusion of entry %d under size-%d checkpoint must verify", seq, tc.cp.Size) } - if got := tc.cp.Size - 1 - uint64(len(p.Links)); got != uint64(seq) { - t.Fatalf("proof position = %d, want %d", got, seq) + if p.Seq != uint64(seq) { + t.Fatalf("proof position = %d, want %d", p.Seq, seq) } } } @@ -317,18 +317,23 @@ func TestInclusionExhaustive(t *testing.T) { if err := half.Seal(a.priv); err != nil { t.Fatalf("Seal: %v", err) } + dishonestLeaves := make([]Hash, 0, 6) + for _, e := range entries { + dishonestLeaves = append(dishonestLeaves, e.ID()) + } + dishonestLeaves = append(dishonestLeaves, half.ID()) dishonest := Checkpoint{ Log: id, Size: cp5.Size + 1, - Head: chainStep(cp5.Head, half.ID()), + Head: mth(dishonestLeaves), IssuedAt: testInstant, } dishonest.Sign(op.priv) if !dishonest.Verify(op.pub) { t.Fatal("setup: dishonest checkpoint should carry a valid operator signature") } - if VerifyInclusion(half, Proof{Prior: cp5.Head}, dishonest, op.pub) { - t.Fatal("a half-sealed entry must never verify as included, even hand-chained by the operator") + if VerifyInclusion(half, Proof{Seq: 5, Path: provePath(5, dishonestLeaves)}, dishonest, op.pub) { + t.Fatal("a half-sealed entry must never verify as included, even hand-treed by the operator") } // A foreign entry (another operator's log) never verifies here. @@ -337,17 +342,23 @@ func TestInclusionExhaustive(t *testing.T) { t.Fatal("a foreign-log entry must not verify as included") } - // Truncated links. - trunc := Proof{Prior: proofs5[0].Prior, Links: proofs5[0].Links[:len(proofs5[0].Links)-1]} + // Truncated audit path. + trunc := Proof{Seq: proofs5[0].Seq, Path: proofs5[0].Path[:len(proofs5[0].Path)-1]} if VerifyInclusion(entries[0], trunc, cp5, op.pub) { - t.Fatal("a truncated Links slice must not verify") + t.Fatal("a truncated audit path must not verify") + } + + // Tampered sibling hash. + tampered := Proof{Seq: proofs5[1].Seq, Path: append([]Hash(nil), proofs5[1].Path...)} + tampered.Path[0][0] ^= 1 + if VerifyInclusion(entries[1], tampered, cp5, op.pub) { + t.Fatal("a tampered audit path must not verify") } - // Wrong prior. - wrongPrior := proofs5[1] - wrongPrior.Prior[0] ^= 1 - if VerifyInclusion(entries[1], wrongPrior, cp5, op.pub) { - t.Fatal("a wrong Prior must not verify") + // Wrong claimed position. + misplaced := Proof{Seq: proofs5[1].Seq + 1, Path: proofs5[1].Path} + if VerifyInclusion(entries[1], misplaced, cp5, op.pub) { + t.Fatal("a proof at the wrong position must not verify") } // Wrong operator key. @@ -408,25 +419,24 @@ func TestConsistency(t *testing.T) { } cp5 := sign(l.Checkpoint(testInstant)) - links, err := l.LeavesSince(2) + links, err := l.ProveConsistency(2) if err != nil { - t.Fatalf("LeavesSince(2): %v", err) + t.Fatalf("ProveConsistency(2): %v", err) } if !VerifyConsistency(cp2, cp5, links, op.pub) { t.Fatal("honest extension from size 2 to 5 must verify") } - all, err := l.LeavesSince(0) - if err != nil { - t.Fatalf("LeavesSince(0): %v", err) + if !VerifyConsistency(cp0, cp5, nil, op.pub) { + t.Fatal("consistency from the empty-log checkpoint needs no proof") } - if !VerifyConsistency(cp0, cp5, all, op.pub) { - t.Fatal("consistency from the empty-log checkpoint must accept the full leaf sequence") + if VerifyConsistency(cp0, cp5, links, op.pub) { + t.Fatal("a non-empty proof against the empty-log checkpoint must not verify") } if VerifyConsistency(cp5, cp2, nil, op.pub) { t.Fatal("newer.Size < older.Size must never verify") } if VerifyConsistency(cp5, cp2, links, op.pub) { - t.Fatal("newer.Size < older.Size must never verify regardless of links") + t.Fatal("newer.Size < older.Size must never verify regardless of proof") } // Fork: the operator rewrites entry 1 (inside cp2's history) and @@ -446,18 +456,22 @@ func TestConsistency(t *testing.T) { } } cpFork5 := sign(lf.Checkpoint(testInstant)) - forkSince2, err := lf.LeavesSince(2) + forkSince2, err := lf.ProveConsistency(2) if err != nil { - t.Fatalf("LeavesSince: %v", err) + t.Fatalf("ProveConsistency: %v", err) + } + garbage := make([]Hash, len(links)) + for i := range garbage { + garbage[i] = HashContent([]byte{byte(200 + i)}) } for name, candidate := range map[string][]Hash{ - "fork LeavesSince(2)": forkSince2, - "honest LeavesSince(2)": links, - "empty": nil, - "full fork leaves": mustLeaves(t, lf, 0), + "fork ProveConsistency(2)": forkSince2, + "honest ProveConsistency(2)": links, + "empty": nil, + "garbage hashes": garbage, } { if VerifyConsistency(cp2, cpFork5, candidate, op.pub) { - t.Fatalf("fork rewriting checkpointed history must fail VerifyConsistency (links: %s)", name) + t.Fatalf("fork rewriting checkpointed history must fail VerifyConsistency (proof: %s)", name) } } // Same-size fork against the honest size-5 checkpoint. @@ -465,12 +479,3 @@ func TestConsistency(t *testing.T) { t.Fatal("a same-size fork must fail VerifyConsistency") } } - -func mustLeaves(t *testing.T, l *Log, since uint64) []Hash { - t.Helper() - leaves, err := l.LeavesSince(since) - if err != nil { - t.Fatalf("LeavesSince(%d): %v", since, err) - } - return leaves -} diff --git a/internal/record/tree.go b/internal/record/tree.go new file mode 100644 index 0000000..d126a89 --- /dev/null +++ b/internal/record/tree.go @@ -0,0 +1,150 @@ +package record + +import ( + "crypto/sha256" + + "github.com/NTARI-RAND/sohocloud-protocol/canon" +) + +// domainNode tags interior Merkle nodes. Leaves keep their own domain +// (drops/leaf/v0, via Entry.ID), so a leaf hash can never be replayed as an +// interior node or vice versa — the RFC 6962 0x00/0x01 prefix discipline, +// expressed with canon domain tags like everything else in the package. +const domainNode = "drops/node/v1" + +// nodeHash combines two subtree hashes into their parent. +func nodeHash(left, right Hash) Hash { + return Hash(sha256.Sum256(canon.New(domainNode).Bytes(left[:]).Bytes(right[:]).Sum())) +} + +// largestPow2LT returns the largest power of two strictly less than n; +// n must be >= 2. +func largestPow2LT(n uint64) uint64 { + k := uint64(1) + for k<<1 < n { + k <<= 1 + } + return k +} + +// mth is the Merkle tree head over leaves (RFC 6962 shape): a single leaf is +// its own subtree hash, and a larger tree splits at the largest power of two +// strictly below its size. mth requires at least one leaf; the empty log's +// head is the LogID seed, handled by Log.Checkpoint. +func mth(leaves []Hash) Hash { + if len(leaves) == 1 { + return leaves[0] + } + k := largestPow2LT(uint64(len(leaves))) + return nodeHash(mth(leaves[:k]), mth(leaves[k:])) +} + +// provePath returns the audit path for the leaf at index m within leaves: +// the sibling subtree hashes from leaf-adjacent first to root-adjacent last. +func provePath(m uint64, leaves []Hash) []Hash { + if len(leaves) == 1 { + return nil + } + k := largestPow2LT(uint64(len(leaves))) + if m < k { + return append(provePath(m, leaves[:k]), mth(leaves[k:])) + } + return append(provePath(m-k, leaves[k:]), mth(leaves[:k])) +} + +// rootFromPath recomputes the tree head implied by placing leaf at index seq +// of a size-entry tree with the given audit path; ok is false when the path +// length does not match the tree shape. Nothing in the path is trusted — +// every hash is recomputed. +func rootFromPath(leaf Hash, seq, size uint64, path []Hash) (Hash, bool) { + if size == 0 || seq >= size { + return Hash{}, false + } + if size == 1 { + if len(path) != 0 { + return Hash{}, false + } + return leaf, true + } + if len(path) == 0 { + return Hash{}, false + } + k := largestPow2LT(size) + sibling := path[len(path)-1] + rest := path[:len(path)-1] + if seq < k { + sub, ok := rootFromPath(leaf, seq, k, rest) + if !ok { + return Hash{}, false + } + return nodeHash(sub, sibling), true + } + sub, ok := rootFromPath(leaf, seq-k, size-k, rest) + if !ok { + return Hash{}, false + } + return nodeHash(sibling, sub), true +} + +// proveConsistency returns the RFC 6962 consistency proof between the tree +// over leaves[:m] and the tree over all leaves (m entries -> n entries, +// 0 < m < n <= len(leaves)). +func proveConsistency(m uint64, leaves []Hash) []Hash { + return subProof(m, leaves, true) +} + +func subProof(m uint64, leaves []Hash, complete bool) []Hash { + n := uint64(len(leaves)) + if m == n { + if complete { + // The old tree is exactly this subtree; the verifier already + // holds its hash (the older checkpoint's head). + return nil + } + return []Hash{mth(leaves)} + } + k := largestPow2LT(n) + if m <= k { + return append(subProof(m, leaves[:k], complete), mth(leaves[k:])) + } + return append(subProof(m-k, leaves[k:], false), mth(leaves[:k])) +} + +// consRoots recomputes, from a consistency proof, the (older, newer) tree +// heads it implies. seed is the older checkpoint's head, standing in for the +// omitted subtree hash along the complete-prefix spine (RFC 6962's b=true). +// The proof is consumed root-adjacent end first; ok is false on any shape +// mismatch. +func consRoots(m, n uint64, complete bool, proof []Hash, seed Hash) (older, newer Hash, rest []Hash, ok bool) { + if m == n { + if complete { + return seed, seed, proof, true + } + if len(proof) == 0 { + return Hash{}, Hash{}, nil, false + } + h := proof[len(proof)-1] + return h, h, proof[:len(proof)-1], true + } + if m > n || len(proof) == 0 { + return Hash{}, Hash{}, nil, false + } + k := largestPow2LT(n) + last := proof[len(proof)-1] + rest = proof[:len(proof)-1] + if m <= k { + oldR, newR, remaining, subOK := consRoots(m, k, complete, rest, seed) + if !subOK { + return Hash{}, Hash{}, nil, false + } + // last is the hash of the new entries' subtree (leaves[k:n]); the + // old tree lies entirely in the left subtree. + return oldR, nodeHash(newR, last), remaining, true + } + oldR, newR, remaining, subOK := consRoots(m-k, n-k, false, rest, seed) + if !subOK { + return Hash{}, Hash{}, nil, false + } + // last is the full left subtree (leaves[:k]), shared by both trees. + return nodeHash(last, oldR), nodeHash(last, newR), remaining, true +} diff --git a/internal/record/tree_test.go b/internal/record/tree_test.go new file mode 100644 index 0000000..73d8d80 --- /dev/null +++ b/internal/record/tree_test.go @@ -0,0 +1,158 @@ +package record + +import ( + "fmt" + "testing" +) + +// syntheticLeaves returns n distinct deterministic leaf hashes. +func syntheticLeaves(n int) []Hash { + leaves := make([]Hash, n) + for i := range leaves { + leaves[i] = HashContent([]byte(fmt.Sprintf("leaf-%d", i))) + } + return leaves +} + +// TestMerkleInclusionExhaustive proves every leaf of every tree size up to 33 +// (crossing several power-of-two boundaries) against the tree head, and +// rejects every single-bit tamper of position, path, and leaf. +func TestMerkleInclusionExhaustive(t *testing.T) { + for n := 1; n <= 33; n++ { + leaves := syntheticLeaves(n) + root := mth(leaves) + for m := 0; m < n; m++ { + path := provePath(uint64(m), leaves) + got, ok := rootFromPath(leaves[m], uint64(m), uint64(n), path) + if !ok || got != root { + t.Fatalf("n=%d m=%d: honest audit path does not recompute the head", n, m) + } + // Wrong position never verifies (every other position). + for wrong := 0; wrong < n; wrong++ { + if wrong == m { + continue + } + if got, ok := rootFromPath(leaves[m], uint64(wrong), uint64(n), path); ok && got == root { + t.Fatalf("n=%d m=%d: path verified at wrong position %d", n, m, wrong) + } + } + // Tampered path element never verifies. + if len(path) > 0 { + bad := append([]Hash(nil), path...) + bad[0][0] ^= 1 + if got, ok := rootFromPath(leaves[m], uint64(m), uint64(n), bad); ok && got == root { + t.Fatalf("n=%d m=%d: tampered path verified", n, m) + } + } + // Wrong leaf never verifies. + wrongLeaf := HashContent([]byte("not a leaf")) + if got, ok := rootFromPath(wrongLeaf, uint64(m), uint64(n), path); ok && got == root { + t.Fatalf("n=%d m=%d: foreign leaf verified", n, m) + } + // Truncated and padded paths never verify. + if len(path) > 0 { + if got, ok := rootFromPath(leaves[m], uint64(m), uint64(n), path[:len(path)-1]); ok && got == root { + t.Fatalf("n=%d m=%d: truncated path verified", n, m) + } + } + padded := append(append([]Hash(nil), path...), Hash{}) + if got, ok := rootFromPath(leaves[m], uint64(m), uint64(n), padded); ok && got == root { + t.Fatalf("n=%d m=%d: padded path verified", n, m) + } + } + } +} + +// TestMerkleConsistencyExhaustive proves every (older, newer) size pair up to +// 33 and rejects tampers, prefix rewrites, and cross-pair replays. +func TestMerkleConsistencyExhaustive(t *testing.T) { + const max = 33 + leaves := syntheticLeaves(max) + roots := make([]Hash, max+1) // roots[k] = MTH(leaves[:k]), k >= 1 + for k := 1; k <= max; k++ { + roots[k] = mth(leaves[:k]) + } + verify := func(m, n uint64, oldRoot, newRoot Hash, proof []Hash) bool { + oldR, newR, rest, ok := consRoots(m, n, true, proof, oldRoot) + return ok && len(rest) == 0 && oldR == oldRoot && newR == newRoot + } + for n := 2; n <= max; n++ { + for m := 1; m < n; m++ { + proof := proveConsistency(uint64(m), leaves[:n]) + if !verify(uint64(m), uint64(n), roots[m], roots[n], proof) { + t.Fatalf("m=%d n=%d: honest consistency proof rejected", m, n) + } + // Tampered proof element. + if len(proof) > 0 { + bad := append([]Hash(nil), proof...) + bad[len(bad)/2][0] ^= 1 + if verify(uint64(m), uint64(n), roots[m], roots[n], bad) { + t.Fatalf("m=%d n=%d: tampered consistency proof accepted", m, n) + } + } + // A rewritten prefix (different old root) must not verify with any + // honest proof for this pair. + forged := roots[m] + forged[0] ^= 1 + if verify(uint64(m), uint64(n), forged, roots[n], proof) { + t.Fatalf("m=%d n=%d: rewritten prefix accepted", m, n) + } + // A different new root must not verify. + forgedNew := roots[n] + forgedNew[0] ^= 1 + if verify(uint64(m), uint64(n), roots[m], forgedNew, proof) { + t.Fatalf("m=%d n=%d: forged new head accepted", m, n) + } + } + } + // Cross-pair replay: a proof for (m1,n) never verifies as (m2,n), m1 != m2. + n := uint64(21) + for m1 := uint64(1); m1 < n; m1++ { + proof := proveConsistency(m1, leaves[:n]) + for m2 := uint64(1); m2 < n; m2++ { + if m1 == m2 { + continue + } + if verify(m2, n, roots[m2], roots[n], proof) { + t.Fatalf("proof for m=%d replayed as m=%d (n=%d)", m1, m2, n) + } + } + } +} + +// TestLogProveConsistencyMatchesTree ties Log.ProveConsistency to the tree +// primitives through the public API, over a real appended log. +func TestLogProveConsistencyMatchesTree(t *testing.T) { + op := newParty(t) + a, b := newParty(t), newParty(t) + id := LogID(op.pub) + l, err := OpenLog(op.pub, NewMemStore()) + if err != nil { + t.Fatalf("OpenLog: %v", err) + } + sign := func(cp Checkpoint) Checkpoint { + cp.Sign(op.priv) + return cp + } + var cps []Checkpoint + cps = append(cps, sign(l.Checkpoint(testInstant))) + for i := 0; i < 9; i++ { + e := sealedEntry(t, id, a, b, contentN(byte(40+i)), Hash{}, testInstant) + if _, err := l.Append(e); err != nil { + t.Fatalf("Append: %v", err) + } + cps = append(cps, sign(l.Checkpoint(testInstant))) + } + for older := 0; older <= 9; older++ { + proof, err := l.ProveConsistency(uint64(older)) + if err != nil { + t.Fatalf("ProveConsistency(%d): %v", older, err) + } + if !VerifyConsistency(cps[older], cps[9], proof, op.pub) { + t.Fatalf("consistency %d -> 9 rejected", older) + } + } + if _, err := l.ProveConsistency(10); err == nil { + t.Fatal("ProveConsistency beyond the log size must error") + } +} diff --git a/internal/record/verify.go b/internal/record/verify.go index 1008703..23ae15c 100644 --- a/internal/record/verify.go +++ b/internal/record/verify.go @@ -5,19 +5,22 @@ import ( ) // Proof is what a member keeps beside an entry to later prove its inclusion -// under a checkpoint, offline and without the operator. Nothing in it is -// trusted: verification recomputes every hash. +// under a checkpoint, offline and without the operator: the entry's sequence +// number and its RFC-6962 audit path — ~log2(size) hashes however large the +// log grows, which is what makes verification feasible on an entry-level +// device (the open-problem-8 floor). Nothing in it is trusted: verification +// recomputes every hash. type Proof struct { - Prior Hash // chain head immediately before the entry (the LogID seed for the first entry) - Links []Hash // leaf hashes (Entry.ID) of every later entry up to the checkpointed head + Seq uint64 // the entry's position in the log + Path []Hash // sibling subtree hashes, leaf-adjacent first, root-adjacent last } // VerifyInclusion reports whether e is a fully sealed covenant committed at -// position cp.Size-1-len(p.Links) of cp's log: it requires e.Verify(), -// cp.Verify(operator), e.Log == cp.Log, and that folding e.ID() over p.Prior -// and p.Links yields cp.Head. One call checks everything; there is no -// half-verification to forget. It does NOT prove non-inclusion or liveness: -// an operator that never appended the entry simply cannot produce a proof. +// position p.Seq of cp's log: it requires e.Verify(), cp.Verify(operator), +// e.Log == cp.Log, and that the audit path recomputes cp.Head from e.ID(). +// One call checks everything; there is no half-verification to forget. It +// does NOT prove non-inclusion or liveness: an operator that never appended +// the entry simply cannot produce a proof. func VerifyInclusion(e Entry, p Proof, cp Checkpoint, operator ed25519.PublicKey) bool { if !e.Verify() { return false @@ -28,36 +31,35 @@ func VerifyInclusion(e Entry, p Proof, cp Checkpoint, operator ed25519.PublicKey if e.Log != cp.Log { return false } - if cp.Size < uint64(len(p.Links))+1 { - return false - } - h := chainStep(p.Prior, e.ID()) - for _, link := range p.Links { - h = chainStep(h, link) - } - return h == cp.Head + root, ok := rootFromPath(e.ID(), p.Seq, cp.Size, p.Path) + return ok && root == cp.Head } // VerifyConsistency reports whether newer extends older without rewrite: -// both checkpoints verify under operator, same Log, -// newer.Size == older.Size+len(links), and folding links over older.Head -// yields newer.Head. Failure against two operator-signed checkpoints is -// portable fork evidence — this is how any member or witness detects a -// silent edit or deletion. It proves nothing about entries the older -// checkpoint never covered. -func VerifyConsistency(older, newer Checkpoint, links []Hash, operator ed25519.PublicKey) bool { +// both checkpoints verify under operator, same Log, and the RFC-6962 +// consistency proof recomputes BOTH heads — older.Head from its components +// and newer.Head from the same components plus the extension. Failure +// against two operator-signed checkpoints is portable fork evidence — this +// is how any member or witness detects a silent edit or deletion. It proves +// nothing about entries the older checkpoint never covered. +// +// The empty older log (Size 0) is consistent with anything on an empty +// proof; equal sizes are consistent only when the heads are equal. +func VerifyConsistency(older, newer Checkpoint, proof []Hash, operator ed25519.PublicKey) bool { if !older.Verify(operator) || !newer.Verify(operator) { return false } if older.Log != newer.Log { return false } - if newer.Size != older.Size+uint64(len(links)) { + switch { + case older.Size > newer.Size: return false + case older.Size == 0: + return len(proof) == 0 + case older.Size == newer.Size: + return len(proof) == 0 && older.Head == newer.Head } - h := older.Head - for _, link := range links { - h = chainStep(h, link) - } - return h == newer.Head + oldR, newR, rest, ok := consRoots(older.Size, newer.Size, true, proof, older.Head) + return ok && len(rest) == 0 && oldR == older.Head && newR == newer.Head } diff --git a/internal/record/witness.go b/internal/record/witness.go index a07783c..b7778c9 100644 --- a/internal/record/witness.go +++ b/internal/record/witness.go @@ -11,7 +11,7 @@ import ( // domainWitness tags witness countersignature payloads, distinct from the // checkpoint tag, so an operator signature can never pose as a witness // cosignature or vice versa. -const domainWitness = "drops/witness/v0" +const domainWitness = "drops/witness/v1" // witnessPayload is the canonical payload a witness countersigns: the // checkpoint's canonical bytes wrapped under the witness domain tag. @@ -78,11 +78,12 @@ func (w *Witness) Key() ed25519.PublicKey { // Countersign verifies cp under operator, refuses when operator equals the // witness's own key, refuses any rollback or fork against the last -// checkpoint it cosigned for cp.Log (using sinceLeaves as extension evidence -// via VerifyConsistency), then returns its cosignature and remembers cp. -// Countersigning a rewritten head is the one thing a witness exists to never -// do, and here it is enforced, not advised. -func (w *Witness) Countersign(cp Checkpoint, operator ed25519.PublicKey, sinceLeaves []Hash) (Countersignature, error) { +// checkpoint it cosigned for cp.Log (using the RFC-6962 consistency proof as +// extension evidence via VerifyConsistency; Log.ProveConsistency produces +// it), then returns its cosignature and remembers cp. Countersigning a +// rewritten head is the one thing a witness exists to never do, and here it +// is enforced, not advised. +func (w *Witness) Countersign(cp Checkpoint, operator ed25519.PublicKey, consistencyProof []Hash) (Countersignature, error) { if len(w.priv) != ed25519.PrivateKeySize { return Countersignature{}, errors.New("record: witness key is malformed") } @@ -93,7 +94,7 @@ func (w *Witness) Countersign(cp Checkpoint, operator ed25519.PublicKey, sinceLe return Countersignature{}, errors.New("record: checkpoint does not verify under operator") } if prev, seen := w.last[cp.Log]; seen { - if !VerifyConsistency(prev, cp, sinceLeaves, operator) { + if !VerifyConsistency(prev, cp, consistencyProof, operator) { return Countersignature{}, errors.New("record: checkpoint is not a consistent extension of the last cosigned checkpoint (rollback or fork refused)") } } diff --git a/internal/record/witness_test.go b/internal/record/witness_test.go index 5b9f5d9..0344a29 100644 --- a/internal/record/witness_test.go +++ b/internal/record/witness_test.go @@ -40,7 +40,11 @@ func newWitnessFixture(t *testing.T) witnessFixture { } } f.cp5 = sign(l.Checkpoint(testInstant)) - f.links2to5 = mustLeaves(t, l, 2) + proof, err := l.ProveConsistency(2) + if err != nil { + t.Fatalf("ProveConsistency(2): %v", err) + } + f.links2to5 = proof fork := f.cp2 fork.Head[0] ^= 1 From 9742665554cc625d78b442628c9757edef178439 Mon Sep 17 00:00:00 2001 From: Jodson Graves Date: Sun, 12 Jul 2026 21:51:01 -0400 Subject: [PATCH 2/3] feat(record): lifecycle witnessing, filing intake, witness relay, member witnesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Part-IV plumbing, built as code with every stand-in labeled: - Lifecycle log: a second per-operator Merkle log (domain-disjoint from the dialog log at every derivation) whose leaves are claim transitions — file -> adjudicate -> resolve -> seal, monotonic per claim, filed-first, seal-only-after-resolve (a clock never force-seals; dwell is a readable fact, never a verdict). Checkpointed and countersigned by the same witness machinery via the new VerifyAs/CountersignAs generic bindings. - Filing intake: FilingCommitment (hash, type, timestamp, exchange ref — PII discipline at its hardest) + FilingReceipt, the witness's ONE write. Receipts carry an explicit independence label: the consumer API's in-process intake is operator-run and says so (independent=false), the stand-in until real intake witnesses run. - Consumer API: dispute filing now lodges the commitment at the intake BEFORE the registry acts (the operator-absent-at-birth ordering, kept in miniature); every dispute transition appends to the lifecycle log; new surfaces for lifecycle checkpoints/claims, drops inclusion proofs (member-verifiable offline, ~log2(n) hashes), and the consistency proofs witnesses need. - internal/relay: the witness relay — caches, relays, serves, fans out filings; decides nothing (which witnesses count, which checkpoint is official, and settlement all stay with readers); bypassable by design and tested as such. - internal/witnesskit + cmd/witnessd: membership-as-witnessing (the problem-8 lever). Any member runs a witness: poll, verify (log identity re-derived, never taken on the relay's word), countersign, host the one filing write. cmd/witness-relay is the thin relay shell. - Hermetic federation test: two member witnesses federate over the relay, the stand-in label DROPS, a forked checkpoint gathers no honest countersignature (refused by witnesses, not the relay), and filing fan-out yields two verifying, operator-independent receipts. Named residuals, unchanged and unhidden: witness amnesia (restart = trust-on-first-checkpoint), single-operator in-process intake labeling, and no adjudicator key in cloudyd (no ruling transitions producible). Signed-off-by: Jodson Graves --- cmd/witness-relay/main.go | 34 +++ cmd/witnessd/main.go | 69 ++++++ internal/consumerapi/api_slice2_test.go | 126 +++++++++- internal/consumerapi/disputes.go | 97 +++++++- internal/consumerapi/lifecycle_api.go | 194 +++++++++++++++ internal/consumerapi/server.go | 23 ++ internal/record/checkpoint.go | 20 +- internal/record/filing.go | 147 +++++++++++ internal/record/lifecycle.go | 317 ++++++++++++++++++++++++ internal/record/lifecycle_test.go | 238 ++++++++++++++++++ internal/record/verify.go | 8 +- internal/record/witness.go | 13 +- internal/relay/federation_test.go | 292 ++++++++++++++++++++++ internal/relay/relay.go | 229 +++++++++++++++++ internal/witnesskit/witnesskit.go | 263 ++++++++++++++++++++ 15 files changed, 2057 insertions(+), 13 deletions(-) create mode 100644 cmd/witness-relay/main.go create mode 100644 cmd/witnessd/main.go create mode 100644 internal/consumerapi/lifecycle_api.go create mode 100644 internal/record/filing.go create mode 100644 internal/record/lifecycle.go create mode 100644 internal/record/lifecycle_test.go create mode 100644 internal/relay/federation_test.go create mode 100644 internal/relay/relay.go create mode 100644 internal/witnesskit/witnesskit.go diff --git a/cmd/witness-relay/main.go b/cmd/witness-relay/main.go new file mode 100644 index 0000000..1f0a52b --- /dev/null +++ b/cmd/witness-relay/main.go @@ -0,0 +1,34 @@ +// Command witness-relay runs the witness relay: it caches operator +// checkpoints, collects witness countersignatures, serves assembled +// bundles, and fans filing commitments out to witness intakes. It decides +// nothing, ranks nothing, and gates nothing; everything it does remains +// possible without it (operators serve their own checkpoints; filers can +// hit intakes directly). Kill it and the record's guarantees are unchanged +// — only the convenience is gone. Its cache is in-memory and +// reconstructible by design. +package main + +import ( + "flag" + "log" + "net/http" + "strings" + + "github.com/NTARI-RAND/Cloudy/internal/relay" +) + +func main() { + addr := flag.String("addr", ":8090", "listen address") + intakes := flag.String("intakes", "", "comma-separated witness intake base URLs for filing fan-out") + flag.Parse() + + var urls []string + if *intakes != "" { + urls = strings.Split(*intakes, ",") + } + rl := relay.New(urls) + log.Printf("witness-relay: listening on %s; %d filing intakes configured; caches and serves, never adjudicates", *addr, len(urls)) + if err := http.ListenAndServe(*addr, rl.Handler()); err != nil { + log.Fatalf("witness-relay: %v", err) + } +} diff --git a/cmd/witnessd/main.go b/cmd/witnessd/main.go new file mode 100644 index 0000000..37497be --- /dev/null +++ b/cmd/witnessd/main.go @@ -0,0 +1,69 @@ +// Command witnessd is a MEMBER's witness daemon — membership-as-witnessing, +// runnable by anyone with a key and a connection: it polls a relay, refuses +// rollbacks and forks by construction, countersigns honest extensions, and +// hosts the filing intake (the one witness write). Two or more of these, +// run by independent members, are what turns every stand-in label in the +// stack into real federation. +// +// Named residual (the amnesia gap, open problem 2): this process's rollback +// memory starts empty, so its first sight of each log is +// trust-on-first-checkpoint. Run it long-lived; durable witness state is a +// deployment concern the record layer names rather than hides. +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "flag" + "log" + "net/http" + "os" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/witnesskit" +) + +func main() { + relayURL := flag.String("relay", "http://localhost:8090", "witness relay base URL") + intakeAddr := flag.String("intake", ":8091", "listen address for the filing intake") + interval := flag.Duration("interval", 30*time.Second, "poll interval") + seedHex := flag.String("seed", "", "hex ed25519 seed (32 bytes); ephemeral when empty — ephemeral witnesses forget, use a persistent seed in anything but a demo") + flag.Parse() + + var priv ed25519.PrivateKey + if *seedHex == "" { + _, p, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + log.Fatalf("witnessd: generating key: %v", err) + } + priv = p + log.Printf("witnessd: EPHEMERAL key (amnesia on restart is total); pass -seed for anything beyond a demo") + } else { + seed, err := hex.DecodeString(*seedHex) + if err != nil || len(seed) != ed25519.SeedSize { + log.Fatalf("witnessd: -seed must be %d hex bytes", ed25519.SeedSize) + } + priv = ed25519.NewKeyFromSeed(seed) + } + + w := witnesskit.NewWorker(priv, *relayURL) + log.Printf("witnessd: witness key %s; polling %s every %s; filing intake on %s (the one write)", + hex.EncodeToString(w.Key()), *relayURL, *interval, *intakeAddr) + + go func() { + if err := http.ListenAndServe(*intakeAddr, w.IntakeHandler()); err != nil { + log.Printf("witnessd: intake server exited: %v", err) + os.Exit(1) + } + }() + for { + n, err := w.RunOnce() + if err != nil { + log.Printf("witnessd: poll: %v", err) + } else if n > 0 { + log.Printf("witnessd: countersigned %d log(s)", n) + } + time.Sleep(*interval) + } +} diff --git a/internal/consumerapi/api_slice2_test.go b/internal/consumerapi/api_slice2_test.go index 1beea40..d9ee333 100644 --- a/internal/consumerapi/api_slice2_test.go +++ b/internal/consumerapi/api_slice2_test.go @@ -282,7 +282,19 @@ func TestDisputeFileWithdrawLifecycle(t *testing.T) { if err := o.Sign(privA); err != nil { t.Fatal(err) } - rec, body := do(t, h, "POST", "/api/v1/disputes", openDisputeRequest{ + // The filing commitment is signed CLIENT-side over the claim the opening + // will create, and lodged at the intake before the registry acts. + wantID := o.ID() + typeHash := sha256.Sum256([]byte("trade-harm")) + commitment := record.FilingCommitment{ + Claim: record.Hash(wantID), + Exchange: record.Hash(ex), + TypeHash: record.Hash(typeHash), + At: o.OpenedAt, + Filer: pubA, + } + commitment.Sign(privA) + req := openDisputeRequest{ Complainant: hex.EncodeToString(pubA), Respondent: hex.EncodeToString(pubB), Exchange: id, @@ -290,15 +302,33 @@ func TestDisputeFileWithdrawLifecycle(t *testing.T) { Nonce: hex.EncodeToString(o.Nonce[:]), OpenedAt: o.OpenedAt.Format(time.RFC3339Nano), Signature: hex.EncodeToString(o.Signature), - }) + } + req.Filing.TypeHash = hex.EncodeToString(typeHash[:]) + req.Filing.At = o.OpenedAt.Format(time.RFC3339Nano) + req.Filing.Signature = hex.EncodeToString(commitment.Signature) + rec, body := do(t, h, "POST", "/api/v1/disputes", req) if rec.Code != http.StatusOK { t.Fatalf("open dispute: code %d body %s", rec.Code, rec.Body.String()) } dID, _ := body["dispute_id"].(string) - wantID := o.ID() if dID != hex.EncodeToString(wantID[:]) { t.Fatalf("dispute_id %q, want Opening.ID() %q", dID, hex.EncodeToString(wantID[:])) } + // The receipt is present and HONESTLY labeled: the intake runs in the + // operator's process, so it must say independent=false. + receipt, _ := body["filing_receipt"].(map[string]any) + if receipt == nil { + t.Fatal("open dispute must return the filing receipt") + } + if receipt["independent"] != false { + t.Fatal("an operator-run intake must label its receipts non-independent") + } + + // The lifecycle log shows the claim filed, as it happened. + rec, body = do(t, h, "GET", "/api/v1/lifecycle/claims/"+dID, nil) + if rec.Code != http.StatusOK || body["state"] != "filed" { + t.Fatalf("lifecycle claim after open: code %d state %v, want filed", rec.Code, body["state"]) + } rec, body = do(t, h, "GET", "/api/v1/disputes/"+dID, nil) if rec.Code != http.StatusOK || body["state"] != "open" { @@ -329,9 +359,99 @@ func TestDisputeFileWithdrawLifecycle(t *testing.T) { t.Fatalf("case after withdrawal: code %d state %v, want withdrawn", rec.Code, body["state"]) } + // The lifecycle log recorded the resolution as its own transition, and + // the lifecycle checkpoint (stand-in labeled) covers both transitions. + rec, body = do(t, h, "GET", "/api/v1/lifecycle/claims/"+dID, nil) + if rec.Code != http.StatusOK || body["state"] != "resolved" { + t.Fatalf("lifecycle claim after withdrawal: code %d state %v, want resolved", rec.Code, body["state"]) + } + if n := len(body["transitions"].([]any)); n != 2 { + t.Fatalf("lifecycle transitions = %d, want 2 (filed, resolved)", n) + } + rec, body = do(t, h, "GET", "/api/v1/lifecycle/checkpoints", nil) + if rec.Code != http.StatusOK || body["stand_in"] != true || body["size"].(float64) != 2 { + t.Fatalf("lifecycle checkpoint: code %d body %v, want stand_in=true size=2", rec.Code, body) + } + unknown := sha256.Sum256([]byte("no such case")) rec, _ = do(t, h, "GET", "/api/v1/disputes/"+hex.EncodeToString(unknown[:]), nil) if rec.Code != http.StatusNotFound { t.Fatalf("unknown dispute: code %d, want 404", rec.Code) } } + +// TestDropProofVerifiesOffline drives the full member-verification story: +// seal a dialog, fetch its proof and checkpoint, and verify inclusion with +// nothing but the record package's public verifier — no operator trust. +func TestDropProofVerifiesOffline(t *testing.T) { + h := newTestServer(t) + pubA, privA := key(1) + pubB, privB := key(2) + registerMember(t, h, pubA, privA) + registerMember(t, h, pubB, privB) + + // Seal via the API, keeping the client-side entry for verification. + rec, body := do(t, h, "GET", "/api/v1/drops/log", nil) + if rec.Code != http.StatusOK { + t.Fatalf("drops/log: %d", rec.Code) + } + logRaw, _ := hex.DecodeString(body["log_id"].(string)) + opKeyRaw, _ := hex.DecodeString(body["operator_key"].(string)) + var logID record.Hash + copy(logID[:], logRaw) + e, err := record.NewEntry(logID, pubA, pubB, record.HashContent([]byte("n")), record.Hash{}, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + _ = e.Seal(privA) + _ = e.Seal(privB) + rec, body = do(t, h, "POST", "/api/v1/drops", dropRequest{ + Log: body["log_id"].(string), + Proposer: hex.EncodeToString(pubA), + Acceptor: hex.EncodeToString(pubB), + Content: hex.EncodeToString(e.Content[:]), + Nonce: hex.EncodeToString(e.Nonce[:]), + SealedAt: e.SealedAt.Format(time.RFC3339Nano), + ProposerSeal: hex.EncodeToString(e.ProposerSeal), + AcceptorSeal: hex.EncodeToString(e.AcceptorSeal), + }) + if rec.Code != http.StatusOK { + t.Fatalf("POST drops: %d %s", rec.Code, rec.Body.String()) + } + id := body["id"].(string) + + rec, body = do(t, h, "GET", "/api/v1/drops/"+id+"/proof", nil) + if rec.Code != http.StatusOK { + t.Fatalf("proof: %d %s", rec.Code, rec.Body.String()) + } + var p record.Proof + p.Seq = uint64(body["seq"].(float64)) + for _, hstr := range body["path"].([]any) { + raw, _ := hex.DecodeString(hstr.(string)) + var hh record.Hash + copy(hh[:], raw) + p.Path = append(p.Path, hh) + } + cpBody := body["checkpoint"].(map[string]any) + var cp record.Checkpoint + lr, _ := hex.DecodeString(cpBody["log"].(string)) + hr, _ := hex.DecodeString(cpBody["head"].(string)) + copy(cp.Log[:], lr) + copy(cp.Head[:], hr) + cp.Size = uint64(cpBody["size"].(float64)) + cp.IssuedAt, err = time.Parse(time.RFC3339Nano, cpBody["issued_at"].(string)) + if err != nil { + t.Fatal(err) + } + cp.Signature, _ = hex.DecodeString(cpBody["signature"].(string)) + + if !record.VerifyInclusion(e, p, cp, ed25519.PublicKey(opKeyRaw)) { + t.Fatal("the served proof must verify offline against the served checkpoint and operator key") + } + // And the tamper story holds end to end. + bad := e + bad.Content[0] ^= 1 + if record.VerifyInclusion(bad, p, cp, ed25519.PublicKey(opKeyRaw)) { + t.Fatal("a tampered entry must not verify") + } +} diff --git a/internal/consumerapi/disputes.go b/internal/consumerapi/disputes.go index 2d2f9fb..3232b9f 100644 --- a/internal/consumerapi/disputes.go +++ b/internal/consumerapi/disputes.go @@ -1,10 +1,13 @@ package consumerapi import ( + "crypto/sha256" "encoding/json" "net/http" + "time" "github.com/NTARI-RAND/Cloudy/internal/dispute" + "github.com/NTARI-RAND/Cloudy/internal/record" ) type openDisputeRequest struct { @@ -15,6 +18,28 @@ type openDisputeRequest struct { Nonce string `json:"nonce"` // hex 32-byte client-drawn nonce OpenedAt string `json:"opened_at"` Signature string `json:"signature"` // hex ed25519 by the complainant + + // Filing is the complainant-signed FilingCommitment over the claim this + // opening will create: the structural fact lodged at the intake witness + // BEFORE the operator acts. Its fields commit to a hash, a type, a + // timestamp, and an exchange reference — never narrative or identity. + Filing struct { + TypeHash string `json:"type_hash"` // hex digest of the dispute-type label + At string `json:"at"` + Signature string `json:"signature"` // hex ed25519 by the complainant over the commitment + } `json:"filing"` +} + +type filingReceiptView struct { + Witness string `json:"witness"` + ReceivedAt string `json:"received_at"` + Signature string `json:"signature"` + // Independent is false whenever the intake witness is the operator — + // this process's permanent condition until real independent intake + // witnesses exist. False here means: the operator was NOT absent from + // this claim's birth, and the receipt proves intake ordering only + // against the operator's own honesty. Label, not decoration. + Independent bool `json:"independent"` } // handleOpenDispute admits one complainant-signed Opening. The Registry is @@ -71,6 +96,22 @@ func (s *Server) handleOpenDispute(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusBadRequest, "signature must be a 64-byte hex signature") return } + typeHash, ok := decodeHex32(req.Filing.TypeHash) + if !ok { + writeErr(w, http.StatusBadRequest, "filing.type_hash must be a 32-byte hex digest") + return + } + filedAt, ok := parseUTC(req.Filing.At) + if !ok { + writeErr(w, http.StatusBadRequest, "filing.at must be RFC3339") + return + } + filingSig, ok := decodeSig(req.Filing.Signature) + if !ok { + writeErr(w, http.StatusBadRequest, "filing.signature must be a 64-byte hex signature") + return + } + o := dispute.Opening{ Platform: s.platform, Complainant: complainant, @@ -81,14 +122,52 @@ func (s *Server) handleOpenDispute(w http.ResponseWriter, r *http.Request) { OpenedAt: openedAt, Signature: sig, } + claimID := o.ID() + + // Intake FIRST: the filing commitment is acknowledged before the + // operator's registry acts on the opening — the Part-IV ordering, kept + // even while both roles run in one process. + commitment := record.FilingCommitment{ + Claim: record.Hash(claimID), + Exchange: record.Hash(exchange), + TypeHash: record.Hash(typeHash), + At: filedAt, + Filer: complainant, + Signature: filingSig, + } + receipt, err := s.intake.Accept(commitment, time.Now().UTC()) + if err != nil { + writeErr(w, http.StatusBadRequest, "filing commitment refused: "+err.Error()) + return + } + s.mu.Lock() id, err := s.registry.Open(o) + if err == nil { + artifact := sha256.Sum256(o.CanonicalBytes()) + _, err = s.lifeLog.Append(record.Transition{ + Log: s.lifeID, + Claim: record.Hash(id), + Kind: record.KindFiled, + Artifact: record.Hash(artifact), + Exchange: record.Hash(exchange), + At: time.Now().UTC(), + }) + } s.mu.Unlock() if err != nil { writeErr(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"dispute_id": hx(id[:])}) + writeJSON(w, http.StatusOK, map[string]any{ + "dispute_id": hx(id[:]), + "filing_receipt": filingReceiptView{ + Witness: hx(receipt.Witness), + ReceivedAt: receipt.ReceivedAt.UTC().Format(time.RFC3339Nano), + Signature: hx(receipt.Signature), + Independent: receipt.IndependentOf(s.operatorPub), + }, + }) } type disputeView struct { @@ -163,6 +242,22 @@ func (s *Server) handleWithdrawDispute(w http.ResponseWriter, r *http.Request) { } s.mu.Lock() err := s.registry.Withdraw(wd) + if err == nil { + var c dispute.Case + c, cerr := s.registry.Case(dispute.DisputeID(id)) + if cerr == nil { + exchange := c.Exchange() + artifact := sha256.Sum256(wd.CanonicalBytes()) + _, err = s.lifeLog.Append(record.Transition{ + Log: s.lifeID, + Claim: record.Hash(id), + Kind: record.KindResolved, + Artifact: record.Hash(artifact), + Exchange: record.Hash(exchange), + At: time.Now().UTC(), + }) + } + } s.mu.Unlock() if err != nil { writeErr(w, http.StatusBadRequest, err.Error()) diff --git a/internal/consumerapi/lifecycle_api.go b/internal/consumerapi/lifecycle_api.go new file mode 100644 index 0000000..74f4649 --- /dev/null +++ b/internal/consumerapi/lifecycle_api.go @@ -0,0 +1,194 @@ +package consumerapi + +import ( + "fmt" + "net/http" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/record" +) + +// handleLifecycleCheckpoints serves the operator's signed checkpoint over its +// claim-LIFECYCLE log — the "witnessed as it happens" half of Part IV. Like +// the dialog checkpoint surface it carries the honest stand-in label until +// two or more independent witnesses countersign (Phase-3 federation). +func (s *Server) handleLifecycleCheckpoints(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + cp := s.lifeLog.Checkpoint(time.Now().UTC()) + cp.Sign(s.operatorPriv) + s.mu.Unlock() + wc := record.WitnessedCheckpoint{Checkpoint: cp} + writeJSON(w, http.StatusOK, checkpointResponse{ + Log: hx(cp.Log[:]), + Size: cp.Size, + Head: hx(cp.Head[:]), + IssuedAt: cp.IssuedAt.UTC().Format(time.RFC3339Nano), + Signature: hx(cp.Signature), + Countersignatures: []string{}, + StandIn: wc.StandIn(s.operatorPub), + }) +} + +type transitionView struct { + Seq uint64 `json:"seq"` + Kind string `json:"kind"` // filed | adjudicated | resolved | sealed + Artifact string `json:"artifact"` + Exchange string `json:"exchange"` + At string `json:"at"` +} + +type lifecycleClaimResponse struct { + ClaimID string `json:"claim_id"` + State string `json:"state"` + FiledAt string `json:"filed_at,omitempty"` + Transitions []transitionView `json:"transitions"` + // Dwell is deliberately NOT computed here: an unresolved claim's age is + // a readable fact the reader derives from filed_at; the surface renders + // facts, never verdicts (flag-not-finding discipline). +} + +// handleLifecycleClaim serves one claim's transition history — structural +// facts only: kinds, digests, references, instants. The narrative stays +// member-local; the reader may compute dwell but is handed no judgment. +func (s *Server) handleLifecycleClaim(w http.ResponseWriter, r *http.Request) { + id, ok := decodeHex32(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusBadRequest, "id must be a 32-byte hex claim ID") + return + } + claim := record.Hash(id) + s.mu.Lock() + seqs := s.lifeLog.Claim(claim) + state, seen := s.lifeLog.State(claim) + views := make([]transitionView, 0, len(seqs)) + var filedAt string + for _, seq := range seqs { + tr, err := s.lifeStore.At(seq) + if err != nil { + continue + } + v := transitionView{ + Seq: seq, + Kind: tr.Kind.String(), + Artifact: hx(tr.Artifact[:]), + Exchange: hx(tr.Exchange[:]), + At: tr.At.UTC().Format(time.RFC3339Nano), + } + if tr.Kind == record.KindFiled { + filedAt = v.At + } + views = append(views, v) + } + s.mu.Unlock() + if !seen { + writeErr(w, http.StatusNotFound, "no lifecycle transitions for this claim") + return + } + writeJSON(w, http.StatusOK, lifecycleClaimResponse{ + ClaimID: r.PathValue("id"), + State: state.String(), + FiledAt: filedAt, + Transitions: views, + }) +} + +type consistencyResponse struct { + From uint64 `json:"from"` + Size uint64 `json:"size"` + Proof []string `json:"proof"` // RFC-6962 consistency proof hashes +} + +// handleDropsConsistency serves the extension evidence a witness needs +// before countersigning a newer dialog-log checkpoint: the consistency +// proof from its last-seen size to the current tree. +func (s *Server) handleDropsConsistency(w http.ResponseWriter, r *http.Request) { + s.serveConsistency(w, r, func(from uint64) ([]record.Hash, uint64, error) { + s.mu.Lock() + defer s.mu.Unlock() + proof, err := s.opLog.ProveConsistency(from) + return proof, s.opLog.Checkpoint(time.Now().UTC()).Size, err + }) +} + +// handleLifecycleConsistency is the lifecycle-log twin. +func (s *Server) handleLifecycleConsistency(w http.ResponseWriter, r *http.Request) { + s.serveConsistency(w, r, func(from uint64) ([]record.Hash, uint64, error) { + s.mu.Lock() + defer s.mu.Unlock() + proof, err := s.lifeLog.ProveConsistency(from) + return proof, s.lifeLog.Checkpoint(time.Now().UTC()).Size, err + }) +} + +func (s *Server) serveConsistency(w http.ResponseWriter, r *http.Request, prove func(uint64) ([]record.Hash, uint64, error)) { + var from uint64 + if q := r.URL.Query().Get("from"); q != "" { + if _, err := fmt.Sscanf(q, "%d", &from); err != nil { + writeErr(w, http.StatusBadRequest, "from must be a non-negative integer") + return + } + } + proof, size, err := prove(from) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + out := consistencyResponse{From: from, Size: size, Proof: make([]string, len(proof))} + for i, h := range proof { + out.Proof[i] = hx(h[:]) + } + writeJSON(w, http.StatusOK, out) +} + +type proofResponse struct { + ID string `json:"id"` + Seq uint64 `json:"seq"` + Path []string `json:"path"` // sibling hashes, leaf-adjacent first + Checkpoint checkpointResponse `json:"checkpoint"` +} + +// handleDropProof serves the RFC-6962 inclusion proof for one sealed dialog +// under a fresh signed checkpoint — everything a member needs to verify, +// offline, that their covenant is in the log: ~log2(size) hashes, feasible +// on an entry-level device however large the log grows. +func (s *Server) handleDropProof(w http.ResponseWriter, r *http.Request) { + id, ok := decodeHex32(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusBadRequest, "id must be a 32-byte hex leaf ID") + return + } + s.mu.Lock() + seq, found := s.index[record.Hash(id)] + var p record.Proof + var cp record.Checkpoint + var err error + if found { + p, err = s.opLog.Prove(seq) + cp = s.opLog.Checkpoint(time.Now().UTC()) + cp.Sign(s.operatorPriv) + } + s.mu.Unlock() + if !found || err != nil { + writeErr(w, http.StatusNotFound, "no entry with this leaf ID") + return + } + path := make([]string, len(p.Path)) + for i, h := range p.Path { + path[i] = hx(h[:]) + } + wc := record.WitnessedCheckpoint{Checkpoint: cp} + writeJSON(w, http.StatusOK, proofResponse{ + ID: r.PathValue("id"), + Seq: p.Seq, + Path: path, + Checkpoint: checkpointResponse{ + Log: hx(cp.Log[:]), + Size: cp.Size, + Head: hx(cp.Head[:]), + IssuedAt: cp.IssuedAt.UTC().Format(time.RFC3339Nano), + Signature: hx(cp.Signature), + Countersignatures: []string{}, + StandIn: wc.StandIn(s.operatorPub), + }, + }) +} diff --git a/internal/consumerapi/server.go b/internal/consumerapi/server.go index 8bea3d1..5811017 100644 --- a/internal/consumerapi/server.go +++ b/internal/consumerapi/server.go @@ -89,6 +89,17 @@ type Server struct { ecoStore *economy.MemStore registry *dispute.Registry + // The claim-lifecycle log (Part IV): every dispute transition commits + // here as it happens, checkpointed and witnessable exactly like the + // dialog log. The filing intake is the ONE witness write; in this + // process it is operator-run, so every receipt it issues is honestly + // labeled non-independent — the stand-in until the witness relay and + // real independent intake witnesses exist (Phase 3). + lifeLog *record.LifecycleLog + lifeStore *record.MemTransitionStore + lifeID record.Hash + intake *record.FilingIntake + // manifest maps a commons artifact id (hex) to the Locker hashes of its // member-local narrative — the front-end-local index that lets a reader // fetch narrative the commons deliberately does not carry. @@ -261,6 +272,13 @@ func NewServer(platform string) (*Server, error) { if err != nil { return nil, err } + s.lifeStore = record.NewMemTransitionStore() + s.lifeLog, err = record.OpenLifecycleLog(operatorPub, s.lifeStore) + if err != nil { + return nil, err + } + s.lifeID = record.LifecycleLogID(operatorPub) + s.intake = record.NewFilingIntake(operatorPriv) return s, nil } @@ -288,6 +306,11 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/v1/disputes", s.handleOpenDispute) mux.HandleFunc("GET /api/v1/disputes/{id}", s.handleGetDispute) mux.HandleFunc("POST /api/v1/disputes/{id}/withdraw", s.handleWithdrawDispute) + mux.HandleFunc("GET /api/v1/lifecycle/checkpoints", s.handleLifecycleCheckpoints) + mux.HandleFunc("GET /api/v1/drops/checkpoints/consistency", s.handleDropsConsistency) + mux.HandleFunc("GET /api/v1/lifecycle/checkpoints/consistency", s.handleLifecycleConsistency) + mux.HandleFunc("GET /api/v1/lifecycle/claims/{id}", s.handleLifecycleClaim) + mux.HandleFunc("GET /api/v1/drops/{id}/proof", s.handleDropProof) return mux } diff --git a/internal/record/checkpoint.go b/internal/record/checkpoint.go index 569b484..1c09ca4 100644 --- a/internal/record/checkpoint.go +++ b/internal/record/checkpoint.go @@ -41,11 +41,21 @@ func (c *Checkpoint) Sign(priv ed25519.PrivateKey) { } // Verify reports whether Signature is a valid operator signature AND -// c.Log == LogID(operator), so a checkpoint can never be replayed against -// another operator's log. +// c.Log == LogID(operator) — the operator's DIALOG log. A checkpoint can +// never be replayed against another operator's log, and (because the +// lifecycle log has a domain-distinct identity) never against the same +// operator's lifecycle log either. func (c Checkpoint) Verify(operator ed25519.PublicKey) bool { - return len(operator) == ed25519.PublicKeySize && + return c.VerifyAs(operator, LogID(operator)) +} + +// VerifyAs reports whether Signature is a valid signature by signer AND +// c.Log equals the expected log identity — the generic binding used for the +// lifecycle log (VerifyAs(operator, LifecycleLogID(operator))) and any +// future per-operator log kind. +func (c Checkpoint) VerifyAs(signer ed25519.PublicKey, wantLog Hash) bool { + return len(signer) == ed25519.PublicKeySize && len(c.Signature) == ed25519.SignatureSize && - c.Log == LogID(operator) && - ed25519.Verify(operator, c.CanonicalBytes(), c.Signature) + c.Log == wantLog && + ed25519.Verify(signer, c.CanonicalBytes(), c.Signature) } diff --git a/internal/record/filing.go b/internal/record/filing.go new file mode 100644 index 0000000..333e647 --- /dev/null +++ b/internal/record/filing.go @@ -0,0 +1,147 @@ +package record + +import ( + "crypto/ed25519" + "errors" + "time" + + "github.com/NTARI-RAND/sohocloud-protocol/canon" +) + +// Filing intake — the deliberate, bounded exception to the witness-as- +// observer role (architecture, Record invariants): a harm claim's filing +// commitment is made AT AN INDEPENDENT WITNESS, upstream of the operator +// that will adjudicate it, so the operator is absent from its own claim's +// birth — it cannot add filing friction, shape a claim, or shed it before +// the claim exists in the record. The witness accepts exactly ONE write — +// claim-creation — and nothing else; it MUST NOT become a second log for any +// other event. +// +// PII discipline binds hardest here: a commitment is a claim id, an exchange +// reference, a TYPE hash, an instant, and the filer's key — the structural +// fact that a claim of a kind exists. The narrative and the identities stay +// front-end-local and erasable. +const ( + domainFiling = "drops/lifecycle-filing/v1" + domainFilingReceipt = "drops/lifecycle-filing-receipt/v1" +) + +// FilingCommitment is the filer-signed structural fact lodged at a witness +// before the operator acts. +type FilingCommitment struct { + Claim Hash // the claim id the dispute layer will use (opaque here) + Exchange Hash // leaf ID of the disputed sealed dialog + TypeHash Hash // digest of the dispute-type label; a hash, never text + At time.Time // UTC instant of filing + Filer ed25519.PublicKey // who files; signs the commitment + Signature []byte // ed25519 by Filer; excluded from CanonicalBytes +} + +// CanonicalBytes returns the deterministic signing payload. +func (f FilingCommitment) CanonicalBytes() []byte { + b := canon.New(domainFiling) + b.Bytes(f.Claim[:]) + b.Bytes(f.Exchange[:]) + b.Bytes(f.TypeHash[:]) + b.Time(f.At) + b.Bytes(f.Filer) + return b.Sum() +} + +// Sign seals the commitment with the filer's key. +func (f *FilingCommitment) Sign(priv ed25519.PrivateKey) { + f.Signature = ed25519.Sign(priv, f.CanonicalBytes()) +} + +// Verify reports whether the commitment is well-formed and filer-signed. +func (f FilingCommitment) Verify() bool { + return len(f.Filer) == ed25519.PublicKeySize && + len(f.Signature) == ed25519.SignatureSize && + f.Claim != zeroHash && + f.Exchange != zeroHash && + ed25519.Verify(f.Filer, f.CanonicalBytes(), f.Signature) +} + +// FilingReceipt is the witness's countersigned acknowledgment that the +// commitment existed at ReceivedAt — the evidence an operator cannot erase a +// filing it never saw or backdate one it sat on. A receipt confers no +// authority: it proves intake, not merit. +type FilingReceipt struct { + Commitment FilingCommitment + Witness ed25519.PublicKey // the intake witness + ReceivedAt time.Time // UTC instant of intake at the witness + Signature []byte // ed25519 by Witness over the receipt payload +} + +// CanonicalBytes returns the witness's signing payload: the commitment's +// canonical bytes wrapped with the intake instant under the receipt domain. +func (r FilingReceipt) CanonicalBytes() []byte { + b := canon.New(domainFilingReceipt) + b.Bytes(r.Commitment.CanonicalBytes()) + b.Bytes(r.Witness) + b.Time(r.ReceivedAt) + return b.Sum() +} + +// Verify reports whether the receipt is a valid witness acknowledgment of a +// valid commitment, and that the witness is not the filer (an intake witness +// acknowledging its own filing proves nothing). +func (r FilingReceipt) Verify() bool { + if !r.Commitment.Verify() { + return false + } + if len(r.Witness) != ed25519.PublicKeySize || len(r.Signature) != ed25519.SignatureSize { + return false + } + if string(r.Witness) == string(r.Commitment.Filer) { + return false + } + return ed25519.Verify(r.Witness, r.CanonicalBytes(), r.Signature) +} + +// IndependentOf reports whether the receipt's witness is independent of the +// given operator key — the label a surface must carry when it is not: an +// operator-run intake is a stand-in, exactly like a single-witness +// checkpoint, and must present itself as one. +func (r FilingReceipt) IndependentOf(operator ed25519.PublicKey) bool { + return len(operator) == ed25519.PublicKeySize && string(r.Witness) != string(operator) +} + +// FilingIntake is the witness-side acceptor of the ONE write. It is +// stateless beyond its key: durability of accepted receipts is the relay's +// concern (cache-and-serve), and the intake never reads, orders, or judges — +// it acknowledges existence at an instant. +type FilingIntake struct { + priv ed25519.PrivateKey + pub ed25519.PublicKey +} + +// NewFilingIntake returns an intake acceptor for the witness key. +func NewFilingIntake(priv ed25519.PrivateKey) *FilingIntake { + fi := &FilingIntake{priv: priv} + if len(priv) == ed25519.PrivateKeySize { + fi.pub = priv.Public().(ed25519.PublicKey) + } + return fi +} + +// Key returns the intake witness's public key. +func (fi *FilingIntake) Key() ed25519.PublicKey { return fi.pub } + +// Accept validates the commitment and returns the signed receipt. It is the +// witness's only verb here — there is no read-back, no veto beyond +// malformedness, and no second write. +func (fi *FilingIntake) Accept(f FilingCommitment, at time.Time) (FilingReceipt, error) { + if len(fi.priv) != ed25519.PrivateKeySize { + return FilingReceipt{}, errors.New("record: intake key is malformed") + } + if !f.Verify() { + return FilingReceipt{}, errors.New("record: filing commitment does not verify") + } + if string(f.Filer) == string(fi.pub) { + return FilingReceipt{}, errors.New("record: an intake witness never accepts its own filing") + } + r := FilingReceipt{Commitment: f, Witness: fi.pub, ReceivedAt: at} + r.Signature = ed25519.Sign(fi.priv, r.CanonicalBytes()) + return r, nil +} diff --git a/internal/record/lifecycle.go b/internal/record/lifecycle.go new file mode 100644 index 0000000..1312a02 --- /dev/null +++ b/internal/record/lifecycle.go @@ -0,0 +1,317 @@ +package record + +import ( + "crypto/ed25519" + "crypto/sha256" + "errors" + "fmt" + "time" + + "github.com/NTARI-RAND/sohocloud-protocol/canon" +) + +// The claim-lifecycle log. Part IV of the architecture: "the witnessed unit +// is the claim lifecycle, not only the sealed verdict" — a harm claim's +// filing, adjudication, resolution, and seal each commit to the witnessed +// record AS THEY HAPPEN, a sequence of monotonic, independently-witnessed +// transitions, never one terminal block delivered whole. +// +// The lifecycle log is a SECOND per-operator Merkle log, wholly disjoint +// from the dialog log by domain tags: its log identity, leaves, and +// checkpoint payloads all carry lifecycle-specific tags, so no lifecycle +// artifact can ever pose as a dialog artifact or vice versa. Transitions are +// operator-RECORDED structural facts (claim id, kind, artifact hash, +// exchange ref, instant) — no narrative, no party keys beyond what the +// dispute registry already carries, no PII. Their integrity comes from the +// same place the dialog log's does: operator-signed monotonic checkpoints, +// independently countersigned. The FILING transition additionally has an +// upstream twin — the FilingCommitment lodged at an independent witness +// BEFORE the operator acts (filing.go) — so an operator that never logs a +// filed claim is caught by cross-checking witness receipts against its +// lifecycle log, and an operator that files late is caught by the receipt's +// earlier instant. +const ( + domainLifeLogID = "drops/lifecycle-logid/v1" + domainLifeTransition = "drops/lifecycle-transition/v1" + domainLifeLeaf = "drops/lifecycle-leaf/v1" +) + +// LifecycleLogID derives the identity of an operator's lifecycle log — +// distinct by domain from the same operator's dialog LogID, so checkpoints +// can never be replayed between the two logs. +func LifecycleLogID(operator ed25519.PublicKey) Hash { + return Hash(sha256.Sum256(canon.New(domainLifeLogID).Bytes(operator).Sum())) +} + +// TransitionKind is a claim's position in the lifecycle pipeline. +type TransitionKind uint8 + +const ( + // KindFiled: the claim exists. Its upstream twin is the FilingCommitment + // at an independent witness; the operator's own Filed transition is the + // downstream acknowledgment. + KindFiled TransitionKind = iota + 1 + // KindAdjudicated: the operator's panel produced a ruling artifact. + KindAdjudicated + // KindResolved: the claim reached an outcome (ruling accepted, or the + // complainant withdrew). Dwell — the age of a claim not yet resolved — + // is read from the gap after KindFiled; a clock never force-seals it. + KindResolved + // KindSealed: the underlying dialog sealed with the claim answered. + KindSealed +) + +// String names the kind for legible surfaces. +func (k TransitionKind) String() string { + switch k { + case KindFiled: + return "filed" + case KindAdjudicated: + return "adjudicated" + case KindResolved: + return "resolved" + case KindSealed: + return "sealed" + } + return "invalid" +} + +// Transition is one structural fact about one claim: fixed-size hashes, a +// kind, and a UTC instant — no string field, no narrative, no identity +// beyond opaque references. The artifact hash commits to the dispute-layer +// artifact (opening, ruling, withdrawal) whose bytes live in the dispute +// registry's own append-only store; the lifecycle log holds only the digest. +type Transition struct { + Log Hash // LifecycleLogID of the one operator lifecycle log this may enter + Claim Hash // opaque claim id (the dispute layer's DisputeID, held by value) + Kind TransitionKind // filed | adjudicated | resolved | sealed + Artifact Hash // digest of the transition's artifact bytes; opaque here + Exchange Hash // leaf ID of the disputed sealed dialog + At time.Time // UTC instant the transition was recorded +} + +// CanonicalBytes returns the deterministic encoding of the transition. +func (t Transition) CanonicalBytes() []byte { + b := canon.New(domainLifeTransition) + b.Bytes(t.Log[:]) + b.Bytes(t.Claim[:]) + b.Uint64(uint64(t.Kind)) + b.Bytes(t.Artifact[:]) + b.Bytes(t.Exchange[:]) + b.Time(t.At) + return b.Sum() +} + +// ID returns the transition's leaf hash in the lifecycle tree. +func (t Transition) ID() Hash { + return Hash(sha256.Sum256(canon.New(domainLifeLeaf).Bytes(t.CanonicalBytes()).Sum())) +} + +// TransitionStore is the lifecycle log's append-only persistence surface; +// update and delete are absent verbs, exactly as in Store. +type TransitionStore interface { + Append(t Transition) error + At(seq uint64) (Transition, error) + Len() (uint64, error) +} + +// MemTransitionStore is the in-memory TransitionStore. +type MemTransitionStore struct { + transitions []Transition +} + +// NewMemTransitionStore returns an empty in-memory transition store. +func NewMemTransitionStore() *MemTransitionStore { + return &MemTransitionStore{} +} + +// Append implements TransitionStore. +func (s *MemTransitionStore) Append(t Transition) error { + s.transitions = append(s.transitions, t) + return nil +} + +// At implements TransitionStore. +func (s *MemTransitionStore) At(seq uint64) (Transition, error) { + if seq >= uint64(len(s.transitions)) { + return Transition{}, fmt.Errorf("record: transition %d out of range (len %d)", seq, len(s.transitions)) + } + return s.transitions[seq], nil +} + +// Len implements TransitionStore. +func (s *MemTransitionStore) Len() (uint64, error) { + return uint64(len(s.transitions)), nil +} + +// LifecycleLog is one operator's claim-lifecycle record: a Merkle tree over +// transition facts, with per-claim monotonic ordering enforced on admission. +// Like the dialog Log it exports no update, no delete, no merge, and no +// cross-log surface. +type LifecycleLog struct { + id Hash + store TransitionStore + leafs []Hash + index map[Hash]uint64 // transition leaf -> seq + state map[Hash]TransitionKind // claim -> furthest kind admitted + claim map[Hash][]uint64 // claim -> sequences, in order + kinds map[Hash]map[TransitionKind]int // claim -> kind -> count (adjudication may repeat) +} + +// OpenLifecycleLog opens the operator's lifecycle log over s, replaying and +// re-verifying every stored transition through the same admission gates +// Append applies. +func OpenLifecycleLog(operator ed25519.PublicKey, s TransitionStore) (*LifecycleLog, error) { + if len(operator) != ed25519.PublicKeySize { + return nil, errors.New("record: operator key is malformed") + } + if s == nil { + return nil, errors.New("record: nil transition store") + } + l := &LifecycleLog{ + id: LifecycleLogID(operator), + store: s, + index: make(map[Hash]uint64), + state: make(map[Hash]TransitionKind), + claim: make(map[Hash][]uint64), + kinds: make(map[Hash]map[TransitionKind]int), + } + n, err := s.Len() + if err != nil { + return nil, fmt.Errorf("record: reading transition store length: %w", err) + } + for seq := uint64(0); seq < n; seq++ { + t, err := s.At(seq) + if err != nil { + return nil, fmt.Errorf("record: reading stored transition %d: %w", seq, err) + } + if err := l.check(t); err != nil { + return nil, fmt.Errorf("record: stored transition %d does not re-verify: %w", seq, err) + } + l.admit(t) + } + return l, nil +} + +// check applies the admission gates without mutating state. +func (l *LifecycleLog) check(t Transition) error { + if t.Log != l.id { + return errors.New("record: transition is bound to a different lifecycle log") + } + if t.Kind < KindFiled || t.Kind > KindSealed { + return errors.New("record: invalid transition kind") + } + if t.Claim == zeroHash { + return errors.New("record: transition names no claim") + } + if t.Exchange == zeroHash { + return errors.New("record: transition names no exchange") + } + if _, dup := l.index[t.ID()]; dup { + return errors.New("record: transition already appended (duplicate leaf)") + } + last, seen := l.state[t.Claim] + switch { + case !seen: + if t.Kind != KindFiled { + return errors.New("record: a claim's first transition must be its filing") + } + case t.Kind == KindFiled: + return errors.New("record: a claim files exactly once") + case t.Kind == KindAdjudicated: + // Adjudication activity may recur (each ruling artifact is a new + // fact) but never after the claim resolved or sealed. + if last >= KindResolved { + return errors.New("record: adjudication after resolution is out of order") + } + case t.Kind <= last: + return errors.New("record: lifecycle transitions are monotonic per claim") + case t.Kind == KindSealed && last < KindResolved: + return errors.New("record: a claim seals only after it resolves — a clock never force-seals") + } + return nil +} + +// admit adds a checked transition to the in-memory state. +func (l *LifecycleLog) admit(t Transition) { + seq := uint64(len(l.leafs)) + leaf := t.ID() + l.index[leaf] = seq + l.leafs = append(l.leafs, leaf) + l.claim[t.Claim] = append(l.claim[t.Claim], seq) + if t.Kind > l.state[t.Claim] { + l.state[t.Claim] = t.Kind + } + kc := l.kinds[t.Claim] + if kc == nil { + kc = make(map[TransitionKind]int) + l.kinds[t.Claim] = kc + } + kc[t.Kind]++ +} + +// Append admits t through the same gates replay uses and persists it. +func (l *LifecycleLog) Append(t Transition) (uint64, error) { + if err := l.check(t); err != nil { + return 0, err + } + if err := l.store.Append(t); err != nil { + return 0, fmt.Errorf("record: persisting transition: %w", err) + } + seq := uint64(len(l.leafs)) + l.admit(t) + return seq, nil +} + +// Checkpoint returns the unsigned checkpoint over the lifecycle tree at UTC +// instant at. It is the SAME Checkpoint type the dialog log uses — a witness +// countersigns both the same way — but its Log field is the lifecycle log's +// identity and verifiers bind it with VerifyAs(operator, +// LifecycleLogID(operator)), so the two logs' checkpoints can never stand in +// for each other. +func (l *LifecycleLog) Checkpoint(at time.Time) Checkpoint { + size := uint64(len(l.leafs)) + head := l.id + if size > 0 { + head = mth(l.leafs) + } + return Checkpoint{Log: l.id, Size: size, Head: head, IssuedAt: at} +} + +// Prove returns the inclusion proof for the transition at seq. +func (l *LifecycleLog) Prove(seq uint64) (Proof, error) { + size := uint64(len(l.leafs)) + if seq >= size { + return Proof{}, fmt.Errorf("record: sequence %d out of range (size %d)", seq, size) + } + return Proof{Seq: seq, Path: provePath(seq, l.leafs)}, nil +} + +// ProveConsistency returns the consistency proof from `size` to the current +// tree — the witness's extension evidence, identical in shape to the dialog +// log's. +func (l *LifecycleLog) ProveConsistency(size uint64) ([]Hash, error) { + current := uint64(len(l.leafs)) + if size > current { + return nil, fmt.Errorf("record: size %d exceeds log size %d", size, current) + } + if size == 0 || size == current { + return []Hash{}, nil + } + return proveConsistency(size, l.leafs), nil +} + +// Claim returns the sequences of a claim's transitions in log order; the +// empty slice means the log has never seen the claim. +func (l *LifecycleLog) Claim(claim Hash) []uint64 { + return append([]uint64(nil), l.claim[claim]...) +} + +// State returns the furthest lifecycle kind a claim has reached; ok is false +// when the log has never seen the claim. An unresolved claim's DWELL — its +// age past filing — is a readable fact for any scan; it is never a verdict, +// and nothing here closes a claim by clock. +func (l *LifecycleLog) State(claim Hash) (TransitionKind, bool) { + k, ok := l.state[claim] + return k, ok +} diff --git a/internal/record/lifecycle_test.go b/internal/record/lifecycle_test.go new file mode 100644 index 0000000..76b3f3f --- /dev/null +++ b/internal/record/lifecycle_test.go @@ -0,0 +1,238 @@ +package record + +import ( + "testing" + "time" +) + +var lifeInstant = time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) + +func lifeTransition(log Hash, claim byte, kind TransitionKind, at time.Time) Transition { + return Transition{ + Log: log, + Claim: HashContent([]byte{claim}), + Kind: kind, + Artifact: HashContent([]byte{claim, byte(kind)}), + Exchange: HashContent([]byte{claim, 0xEE}), + At: at, + } +} + +func TestLifecycleStateMachine(t *testing.T) { + op := newParty(t) + id := LifecycleLogID(op.pub) + l, err := OpenLifecycleLog(op.pub, NewMemTransitionStore()) + if err != nil { + t.Fatalf("OpenLifecycleLog: %v", err) + } + + // A claim's first transition must be its filing. + if _, err := l.Append(lifeTransition(id, 1, KindAdjudicated, lifeInstant)); err == nil { + t.Fatal("adjudication before filing must be refused") + } + if _, err := l.Append(lifeTransition(id, 1, KindFiled, lifeInstant)); err != nil { + t.Fatalf("filing: %v", err) + } + // A claim files exactly once. + if _, err := l.Append(lifeTransition(id, 1, KindFiled, lifeInstant.Add(time.Minute))); err == nil { + t.Fatal("double filing must be refused") + } + // Adjudication may recur before resolution. + if _, err := l.Append(lifeTransition(id, 1, KindAdjudicated, lifeInstant.Add(2*time.Minute))); err != nil { + t.Fatalf("first adjudication: %v", err) + } + if _, err := l.Append(lifeTransition(id, 1, KindAdjudicated, lifeInstant.Add(3*time.Minute))); err != nil { + t.Fatalf("second adjudication: %v", err) + } + // Seal before resolution must be refused — a clock never force-seals. + if _, err := l.Append(lifeTransition(id, 1, KindSealed, lifeInstant.Add(4*time.Minute))); err == nil { + t.Fatal("seal before resolution must be refused") + } + if _, err := l.Append(lifeTransition(id, 1, KindResolved, lifeInstant.Add(5*time.Minute))); err != nil { + t.Fatalf("resolution: %v", err) + } + // Adjudication after resolution must be refused. + if _, err := l.Append(lifeTransition(id, 1, KindAdjudicated, lifeInstant.Add(6*time.Minute))); err == nil { + t.Fatal("adjudication after resolution must be refused") + } + if _, err := l.Append(lifeTransition(id, 1, KindSealed, lifeInstant.Add(7*time.Minute))); err != nil { + t.Fatalf("seal: %v", err) + } + if k, ok := l.State(HashContent([]byte{1})); !ok || k != KindSealed { + t.Fatalf("state = %v ok=%v, want sealed", k, ok) + } + if got := len(l.Claim(HashContent([]byte{1}))); got != 5 { + t.Fatalf("claim transition count = %d, want 5", got) + } + + // A second claim's dwell is readable: filed, never resolved. + if _, err := l.Append(lifeTransition(id, 2, KindFiled, lifeInstant)); err != nil { + t.Fatalf("second claim filing: %v", err) + } + if k, ok := l.State(HashContent([]byte{2})); !ok || k != KindFiled { + t.Fatalf("unresolved claim state = %v, want filed (dwell readable, never force-sealed)", k) + } + + // Structural gates. + bad := lifeTransition(id, 3, KindFiled, lifeInstant) + bad.Claim = Hash{} + if _, err := l.Append(bad); err == nil { + t.Fatal("zero claim must be refused") + } + bad = lifeTransition(id, 3, KindFiled, lifeInstant) + bad.Exchange = Hash{} + if _, err := l.Append(bad); err == nil { + t.Fatal("zero exchange must be refused") + } + foreign := lifeTransition(LifecycleLogID(newParty(t).pub), 3, KindFiled, lifeInstant) + if _, err := l.Append(foreign); err == nil { + t.Fatal("foreign-log transition must be refused") + } + dup := lifeTransition(id, 2, KindFiled, lifeInstant) + if _, err := l.Append(dup); err == nil { + t.Fatal("duplicate transition (same claim files once) must be refused") + } + + // Replay re-verifies: the same store reopens to the same tree. + // (State-machine order is enforced on replay through the same gates.) + reopened, err := OpenLifecycleLog(op.pub, l.store.(*MemTransitionStore)) + if err != nil { + t.Fatalf("reopen: %v", err) + } + if reopened.Checkpoint(lifeInstant).Head != l.Checkpoint(lifeInstant).Head { + t.Fatal("replayed lifecycle log head differs") + } +} + +func TestLifecycleCheckpointDisjointFromDialog(t *testing.T) { + op := newParty(t) + a, b := newParty(t), newParty(t) + + dialog, err := OpenLog(op.pub, NewMemStore()) + if err != nil { + t.Fatalf("OpenLog: %v", err) + } + e := sealedEntry(t, LogID(op.pub), a, b, contentN(7), Hash{}, lifeInstant) + if _, err := dialog.Append(e); err != nil { + t.Fatalf("Append: %v", err) + } + life, err := OpenLifecycleLog(op.pub, NewMemTransitionStore()) + if err != nil { + t.Fatalf("OpenLifecycleLog: %v", err) + } + if _, err := life.Append(lifeTransition(LifecycleLogID(op.pub), 9, KindFiled, lifeInstant)); err != nil { + t.Fatalf("Append transition: %v", err) + } + + dcp := dialog.Checkpoint(lifeInstant) + dcp.Sign(op.priv) + lcp := life.Checkpoint(lifeInstant) + lcp.Sign(op.priv) + + if !dcp.Verify(op.pub) || !lcp.VerifyAs(op.pub, LifecycleLogID(op.pub)) { + t.Fatal("both checkpoints must verify under their own identities") + } + // Cross-binding is dead in both directions. + if dcp.VerifyAs(op.pub, LifecycleLogID(op.pub)) { + t.Fatal("a dialog checkpoint must never verify as a lifecycle checkpoint") + } + if lcp.Verify(op.pub) { + t.Fatal("a lifecycle checkpoint must never verify as a dialog checkpoint") + } + + // A witness serves both logs, keyed separately, and refuses cross-log use. + w := NewWitness(newParty(t).priv) + if _, err := w.CountersignAs(lcp, op.pub, LifecycleLogID(op.pub), nil); err != nil { + t.Fatalf("CountersignAs lifecycle: %v", err) + } + if _, err := w.Countersign(dcp, op.pub, nil); err != nil { + t.Fatalf("Countersign dialog: %v", err) + } + if _, err := w.CountersignAs(dcp, op.pub, LifecycleLogID(op.pub), nil); err == nil { + t.Fatal("a witness must refuse a dialog checkpoint presented as lifecycle") + } + + // Lifecycle consistency rides the same machinery. + if _, err := life.Append(lifeTransition(LifecycleLogID(op.pub), 9, KindResolved, lifeInstant.Add(time.Minute))); err != nil { + t.Fatalf("Append: %v", err) + } + lcp2 := life.Checkpoint(lifeInstant.Add(time.Minute)) + lcp2.Sign(op.priv) + proof, err := life.ProveConsistency(1) + if err != nil { + t.Fatalf("ProveConsistency: %v", err) + } + if !VerifyConsistencyAs(lcp, lcp2, proof, op.pub, LifecycleLogID(op.pub)) { + t.Fatal("lifecycle consistency must verify") + } + // Inclusion of a transition under the lifecycle checkpoint. + p, err := life.Prove(0) + if err != nil { + t.Fatalf("Prove: %v", err) + } + tr, err := life.store.At(0) + if err != nil { + t.Fatalf("At: %v", err) + } + root, ok := rootFromPath(tr.ID(), p.Seq, lcp2.Size, p.Path) + if !ok || root != lcp2.Head { + t.Fatal("lifecycle inclusion proof must recompute the head") + } +} + +func TestFilingIntake(t *testing.T) { + filer := newParty(t) + witness := newParty(t) + operator := newParty(t) + + f := FilingCommitment{ + Claim: HashContent([]byte("claim")), + Exchange: HashContent([]byte("exchange")), + TypeHash: HashContent([]byte("trade-harm")), + At: lifeInstant, + Filer: filer.pub, + } + f.Sign(filer.priv) + if !f.Verify() { + t.Fatal("signed commitment must verify") + } + + intake := NewFilingIntake(witness.priv) + r, err := intake.Accept(f, lifeInstant.Add(time.Second)) + if err != nil { + t.Fatalf("Accept: %v", err) + } + if !r.Verify() { + t.Fatal("receipt must verify") + } + if !r.IndependentOf(operator.pub) { + t.Fatal("a third-party witness is independent of the operator") + } + // The operator running its own intake is the labeled stand-in. + opIntake := NewFilingIntake(operator.priv) + r2, err := opIntake.Accept(f, lifeInstant.Add(time.Second)) + if err != nil { + t.Fatalf("Accept: %v", err) + } + if r2.IndependentOf(operator.pub) { + t.Fatal("an operator-run intake must label itself non-independent") + } + + // Tampering is caught. + bad := r + bad.Commitment.Exchange[0] ^= 1 + if bad.Verify() { + t.Fatal("tampered receipt must not verify") + } + // The intake refuses the filer's own key as witness. + selfIntake := NewFilingIntake(filer.priv) + if _, err := selfIntake.Accept(f, lifeInstant); err == nil { + t.Fatal("an intake witness never accepts its own filing") + } + // An unsigned commitment is refused. + unsigned := f + unsigned.Signature = nil + if _, err := intake.Accept(unsigned, lifeInstant); err == nil { + t.Fatal("an unsigned commitment must be refused") + } +} diff --git a/internal/record/verify.go b/internal/record/verify.go index 23ae15c..29494bd 100644 --- a/internal/record/verify.go +++ b/internal/record/verify.go @@ -46,7 +46,13 @@ func VerifyInclusion(e Entry, p Proof, cp Checkpoint, operator ed25519.PublicKey // The empty older log (Size 0) is consistent with anything on an empty // proof; equal sizes are consistent only when the heads are equal. func VerifyConsistency(older, newer Checkpoint, proof []Hash, operator ed25519.PublicKey) bool { - if !older.Verify(operator) || !newer.Verify(operator) { + return VerifyConsistencyAs(older, newer, proof, operator, LogID(operator)) +} + +// VerifyConsistencyAs is VerifyConsistency with an explicit log identity — +// used for the lifecycle log and any future per-operator log kind. +func VerifyConsistencyAs(older, newer Checkpoint, proof []Hash, signer ed25519.PublicKey, wantLog Hash) bool { + if !older.VerifyAs(signer, wantLog) || !newer.VerifyAs(signer, wantLog) { return false } if older.Log != newer.Log { diff --git a/internal/record/witness.go b/internal/record/witness.go index b7778c9..0f52b57 100644 --- a/internal/record/witness.go +++ b/internal/record/witness.go @@ -84,17 +84,24 @@ func (w *Witness) Key() ed25519.PublicKey { // rewritten head is the one thing a witness exists to never do, and here it // is enforced, not advised. func (w *Witness) Countersign(cp Checkpoint, operator ed25519.PublicKey, consistencyProof []Hash) (Countersignature, error) { + return w.CountersignAs(cp, operator, LogID(operator), consistencyProof) +} + +// CountersignAs is Countersign with an explicit log identity, so the same +// witness (and the same per-log rollback memory, keyed by log identity) +// serves dialog logs and lifecycle logs alike. +func (w *Witness) CountersignAs(cp Checkpoint, operator ed25519.PublicKey, wantLog Hash, consistencyProof []Hash) (Countersignature, error) { if len(w.priv) != ed25519.PrivateKeySize { return Countersignature{}, errors.New("record: witness key is malformed") } if bytes.Equal(operator, w.pub) { return Countersignature{}, errors.New("record: a witness never countersigns its own operator") } - if !cp.Verify(operator) { - return Countersignature{}, errors.New("record: checkpoint does not verify under operator") + if !cp.VerifyAs(operator, wantLog) { + return Countersignature{}, errors.New("record: checkpoint does not verify under operator for this log") } if prev, seen := w.last[cp.Log]; seen { - if !VerifyConsistency(prev, cp, consistencyProof, operator) { + if !VerifyConsistencyAs(prev, cp, consistencyProof, operator, wantLog) { return Countersignature{}, errors.New("record: checkpoint is not a consistent extension of the last cosigned checkpoint (rollback or fork refused)") } } diff --git a/internal/relay/federation_test.go b/internal/relay/federation_test.go new file mode 100644 index 0000000..ab07187 --- /dev/null +++ b/internal/relay/federation_test.go @@ -0,0 +1,292 @@ +package relay_test + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/record" + "github.com/NTARI-RAND/Cloudy/internal/relay" + "github.com/NTARI-RAND/Cloudy/internal/witnesskit" +) + +func key(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return pub, priv +} + +func sealed(t *testing.T, logID record.Hash, aPub ed25519.PublicKey, aPriv ed25519.PrivateKey, bPub ed25519.PublicKey, bPriv ed25519.PrivateKey, n byte) record.Entry { + t.Helper() + e, err := record.NewEntry(logID, aPub, bPub, record.HashContent([]byte{n}), record.Hash{}, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if err := e.Seal(aPriv); err != nil { + t.Fatal(err) + } + if err := e.Seal(bPriv); err != nil { + t.Fatal(err) + } + return e +} + +func postJSON(t *testing.T, url string, v any) *http.Response { + t.Helper() + body, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + return resp +} + +// TestFederationDropsTheStandInLabel is the record layer's whole point run +// hermetically: an operator publishes checkpoints; two independent MEMBER +// witnesses (witnesskit workers) poll the relay, verify, refuse nothing +// honest, and countersign; the assembled bundle verifies with independence +// and the stand-in label drops. The relay decides nothing anywhere in the +// flow. "Federation is structurally cheap: an independent witness joins by +// appending a countersignature" — here are two, joining by exactly that. +func TestFederationDropsTheStandInLabel(t *testing.T) { + opPub, opPriv := key(t) + aPub, aPriv := key(t) + bPub, bPriv := key(t) + + // Operator side: a real dialog log with two sealed covenants. + log, err := record.OpenLog(opPub, record.NewMemStore()) + if err != nil { + t.Fatal(err) + } + if _, err := log.Append(sealed(t, record.LogID(opPub), aPub, aPriv, bPub, bPriv, 1)); err != nil { + t.Fatal(err) + } + + rl := relay.New(nil) + srv := httptest.NewServer(rl.Handler()) + defer srv.Close() + + publish := func(from uint64) record.Checkpoint { + cp := log.Checkpoint(time.Now().UTC()) + cp.Sign(opPriv) + proof, err := log.ProveConsistency(from) + if err != nil { + t.Fatal(err) + } + msg := witnesskit.EncodeCheckpoint(cp, opPub, "dialog", from, proof) + resp := postJSON(t, srv.URL+"/v1/logs/"+msg.Log+"/checkpoints", msg) + if resp.StatusCode != http.StatusOK { + t.Fatalf("publish: %d", resp.StatusCode) + } + resp.Body.Close() + return cp + } + cp1 := publish(0) + + // Two member witnesses join by running the boring worker. + w1 := witnesskit.NewWorker(mustPriv(t), srv.URL) + w2 := witnesskit.NewWorker(mustPriv(t), srv.URL) + for _, w := range []*witnesskit.Worker{w1, w2} { + n, err := w.RunOnce() + if err != nil || n != 1 { + t.Fatalf("RunOnce: n=%d err=%v", n, err) + } + } + + // The reader assembles the bundle and applies ITS OWN independence + // checks — the relay handed over everything unranked. + bundle := fetchBundle(t, srv.URL, hex.EncodeToString(cp1.Log[:])) + wc := reconstruct(t, bundle) + if !wc.Verify(opPub) { + t.Fatal("federated bundle must verify") + } + if wc.StandIn(opPub) { + t.Fatal("two independent member witnesses countersigned; the stand-in label must drop") + } + + // The log grows; witnesses countersign the extension (their rollback + // memory verifying consistency), and the new bundle federates too. + if _, err := log.Append(sealed(t, record.LogID(opPub), aPub, aPriv, bPub, bPriv, 2)); err != nil { + t.Fatal(err) + } + cp2 := publish(cp1.Size) + for _, w := range []*witnesskit.Worker{w1, w2} { + if n, err := w.RunOnce(); err != nil || n != 1 { + t.Fatalf("RunOnce after extension: n=%d err=%v", n, err) + } + } + bundle = fetchBundle(t, srv.URL, hex.EncodeToString(cp2.Log[:])) + wc = reconstruct(t, bundle) + if wc.Checkpoint.Size != 2 || !wc.Verify(opPub) || wc.StandIn(opPub) { + t.Fatal("extended checkpoint must federate the same way") + } + + // A rewriting operator is refused BY THE WITNESSES, not the relay: a + // forked checkpoint at the same size, published to the relay (which + // caches shape without judgment), gathers no honest countersignature. + fork := cp2 + fork.Head[0] ^= 1 + fork.Sign(opPriv) + forkMsg := witnesskit.EncodeCheckpoint(fork, opPub, "dialog", cp2.Size, nil) + forkMsg.Size = cp2.Size + 1 // lie about growth so the relay caches it + resp := postJSON(t, srv.URL+"/v1/logs/"+forkMsg.Log+"/checkpoints", forkMsg) + resp.Body.Close() + for _, w := range []*witnesskit.Worker{w1, w2} { + if n, _ := w.RunOnce(); n != 0 { + t.Fatal("a witness countersigned a fork; the one thing it exists to never do") + } + } +} + +// TestFilingFanOut: the relay forwards the one witness write to every +// intake and returns receipts; each receipt verifies and is independent of +// the operator. A filer without a relay hits an intake directly — same +// result, proving the relay is convenience, not dependency. +func TestFilingFanOut(t *testing.T) { + opPub, _ := key(t) + filerPub, filerPriv := key(t) + + w1 := witnesskit.NewWorker(mustPriv(t), "") + w2 := witnesskit.NewWorker(mustPriv(t), "") + i1 := httptest.NewServer(w1.IntakeHandler()) + defer i1.Close() + i2 := httptest.NewServer(w2.IntakeHandler()) + defer i2.Close() + + rl := relay.New([]string{i1.URL, i2.URL}) + srv := httptest.NewServer(rl.Handler()) + defer srv.Close() + + f := record.FilingCommitment{ + Claim: record.HashContent([]byte("claim")), + Exchange: record.HashContent([]byte("exchange")), + TypeHash: record.HashContent([]byte("trade-harm")), + At: time.Now().UTC(), + Filer: filerPub, + } + f.Sign(filerPriv) + msg := witnesskit.FilingMsg{ + Claim: hex.EncodeToString(f.Claim[:]), + Exchange: hex.EncodeToString(f.Exchange[:]), + TypeHash: hex.EncodeToString(f.TypeHash[:]), + At: f.At.Format(time.RFC3339Nano), + Filer: hex.EncodeToString(filerPub), + Signature: hex.EncodeToString(f.Signature), + } + + resp := postJSON(t, srv.URL+"/v1/filings", msg) + defer resp.Body.Close() + var out struct { + Receipts []witnesskit.ReceiptMsg `json:"receipts"` + Intakes int `json:"intakes"` + Failures int `json:"failures"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + if out.Intakes != 2 || out.Failures != 0 || len(out.Receipts) != 2 { + t.Fatalf("fan-out: %+v", out) + } + seen := map[string]bool{} + for _, r := range out.Receipts { + receipt := reconstructReceipt(t, f, r) + if !receipt.Verify() { + t.Fatal("fan-out receipt must verify") + } + if !receipt.IndependentOf(opPub) { + t.Fatal("member-witness receipts are independent of the operator") + } + seen[r.Witness] = true + } + if len(seen) != 2 { + t.Fatal("receipts must come from two distinct witnesses") + } + + // Direct-to-intake works without the relay. + resp = postJSON(t, i1.URL+"/v1/filings", msg) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatal("a filer must be able to bypass the relay entirely") + } +} + +func mustPriv(t *testing.T) ed25519.PrivateKey { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return priv +} + +func fetchBundle(t *testing.T, base, logID string) relay.BundleMsg { + t.Helper() + resp, err := http.Get(base + "/v1/logs/" + logID + "/checkpoints/latest") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var bundle relay.BundleMsg + if err := json.NewDecoder(resp.Body).Decode(&bundle); err != nil { + t.Fatal(err) + } + return bundle +} + +func reconstruct(t *testing.T, bundle relay.BundleMsg) record.WitnessedCheckpoint { + t.Helper() + cp, _, _, _, err := witnesskit.DecodeCheckpoint(bundle.Checkpoint) + if err != nil { + t.Fatal(err) + } + wc := record.WitnessedCheckpoint{Checkpoint: cp} + for _, cs := range bundle.Countersignatures { + wRaw, err := hex.DecodeString(cs.Witness) + if err != nil { + t.Fatal(err) + } + sRaw, err := hex.DecodeString(cs.Signature) + if err != nil { + t.Fatal(err) + } + wc.Countersignatures = append(wc.Countersignatures, record.Countersignature{ + Witness: ed25519.PublicKey(wRaw), + Signature: sRaw, + }) + } + return wc +} + +func reconstructReceipt(t *testing.T, f record.FilingCommitment, r witnesskit.ReceiptMsg) record.FilingReceipt { + t.Helper() + wRaw, err := hex.DecodeString(r.Witness) + if err != nil { + t.Fatal(err) + } + sRaw, err := hex.DecodeString(r.Signature) + if err != nil { + t.Fatal(err) + } + at, err := time.Parse(time.RFC3339Nano, r.ReceivedAt) + if err != nil { + t.Fatal(err) + } + return record.FilingReceipt{ + Commitment: f, + Witness: ed25519.PublicKey(wRaw), + ReceivedAt: at.UTC(), + Signature: sRaw, + } +} diff --git a/internal/relay/relay.go b/internal/relay/relay.go new file mode 100644 index 0000000..65fb598 --- /dev/null +++ b/internal/relay/relay.go @@ -0,0 +1,229 @@ +// Package relay is the witness relay: it schedules nothing it decides, +// relays checkpoints and filings, caches countersignatures, and serves the +// assembled WitnessedCheckpoint bundles. Its non-authority is structural +// and load-bearing (the architecture's witness-relay rule): +// +// - it MUST NOT decide which witnesses count: it caches every +// structurally valid countersignature and serves them all; readers +// apply their own independence checks (WitnessedCheckpoint.Verify and +// StandIn do exactly that); +// - it MUST NOT decide which checkpoint is official: it caches what +// operators publish, keyed by log, keeping only monotonic growth per +// log (a relay that let a log's size run backward would be a rollback +// amplifier, so it refuses regressions — refusing to UNPUBLISH is not +// authority); +// - it MUST NOT gate settlement: nothing consults the relay to decide +// anything; and +// - everything it does must remain possible without it: operators serve +// their own checkpoints, witnesses can poll operators directly, and +// filers can lodge commitments at any witness intake directly. The +// relay is convenience topology, not a dependency. +// +// For filings the relay only FORWARDS: the one witness write (claim +// creation) happens at witness intakes; the relay fans a commitment out to +// the configured intake URLs and returns whatever receipts come back. It +// holds no intake key and can mint no receipt. +package relay + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "sync" + "time" +) + +// CheckpointMsg is the wire form of a checkpoint plus its countersignatures. +// All fields are hex strings; the relay treats them as opaque beyond shape. +type CheckpointMsg struct { + Log string `json:"log"` + Size uint64 `json:"size"` + Head string `json:"head"` + IssuedAt string `json:"issued_at"` + Signature string `json:"signature"` + // Operator is the operator's public key (hex) — carried so witnesses + // polling the relay know what to verify against; the relay itself + // never verifies trust, only shape. + Operator string `json:"operator"` + // Kind names which of the operator's logs this checkpoint commits + // ("dialog" or "lifecycle"); the log id already binds it + // cryptographically, the kind is legibility for pollers. + Kind string `json:"kind"` + // ConsistencyFrom/Proof carry the operator-supplied extension evidence + // from the previous published size, so witnesses can verify without a + // second round trip. A witness MAY ignore this and fetch its own. + ConsistencyFrom uint64 `json:"consistency_from"` + ConsistencyProof []string `json:"consistency_proof"` +} + +// CountersigMsg is one witness countersignature over the checkpoint the +// relay currently caches for a log at a given size. +type CountersigMsg struct { + Log string `json:"log"` + Size uint64 `json:"size"` + Witness string `json:"witness"` + Signature string `json:"signature"` +} + +// BundleMsg is what readers get: the cached checkpoint and every cached +// countersignature for it. The relay does not rank, filter, or count them. +type BundleMsg struct { + Checkpoint CheckpointMsg `json:"checkpoint"` + Countersignatures []CountersigMsg `json:"countersignatures"` +} + +type logState struct { + cp CheckpointMsg + cosign map[string]CountersigMsg // witness hex -> countersig for cp.Size +} + +// Relay is the in-memory relay. Durability is a deployment concern; the +// relay's cache is reconstructible from operators and witnesses by design. +type Relay struct { + mu sync.Mutex + logs map[string]*logState + intakes []string // witness intake base URLs for filing fan-out + client *http.Client +} + +// New returns a relay that fans filings out to the given witness intake +// URLs (each expecting POST {url}/v1/filings). +func New(intakes []string) *Relay { + return &Relay{ + logs: make(map[string]*logState), + intakes: append([]string(nil), intakes...), + client: &http.Client{Timeout: 10 * time.Second}, + } +} + +// Handler mounts the relay's HTTP surface. +func (rl *Relay) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /v1/logs/{log}/checkpoints", rl.handlePublish) + mux.HandleFunc("POST /v1/logs/{log}/countersignatures", rl.handleCountersig) + mux.HandleFunc("GET /v1/logs/{log}/checkpoints/latest", rl.handleLatest) + mux.HandleFunc("GET /v1/logs", rl.handleLogs) + mux.HandleFunc("POST /v1/filings", rl.handleFiling) + return mux +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, code int, msg string) { + writeJSON(w, code, map[string]string{"error": msg}) +} + +// handlePublish caches an operator-published checkpoint. Monotonic per log: +// a smaller-or-equal size does not replace a larger one (refusing rollback +// amplification), but is not an error — the operator may be re-publishing. +func (rl *Relay) handlePublish(w http.ResponseWriter, r *http.Request) { + var msg CheckpointMsg + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + if msg.Log == "" || msg.Log != r.PathValue("log") { + writeErr(w, http.StatusBadRequest, "log path and body disagree") + return + } + rl.mu.Lock() + defer rl.mu.Unlock() + st, ok := rl.logs[msg.Log] + if !ok || msg.Size > st.cp.Size { + rl.logs[msg.Log] = &logState{cp: msg, cosign: make(map[string]CountersigMsg)} + } + writeJSON(w, http.StatusOK, map[string]string{"status": "cached"}) +} + +// handleCountersig caches a witness countersignature for the checkpoint the +// relay currently holds at that log and size. The relay verifies SHAPE only; +// readers verify signatures — the relay counting or vetting witnesses would +// be the authority it must not be. +func (rl *Relay) handleCountersig(w http.ResponseWriter, r *http.Request) { + var msg CountersigMsg + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + if msg.Log != r.PathValue("log") { + writeErr(w, http.StatusBadRequest, "log path and body disagree") + return + } + rl.mu.Lock() + defer rl.mu.Unlock() + st, ok := rl.logs[msg.Log] + if !ok || st.cp.Size != msg.Size { + writeErr(w, http.StatusConflict, "no cached checkpoint at this size; poll latest and countersign that") + return + } + st.cosign[msg.Witness] = msg + writeJSON(w, http.StatusOK, map[string]string{"status": "cached"}) +} + +// handleLatest serves the cached bundle for one log. +func (rl *Relay) handleLatest(w http.ResponseWriter, r *http.Request) { + rl.mu.Lock() + st, ok := rl.logs[r.PathValue("log")] + var bundle BundleMsg + if ok { + bundle.Checkpoint = st.cp + for _, cs := range st.cosign { + bundle.Countersignatures = append(bundle.Countersignatures, cs) + } + } + rl.mu.Unlock() + if !ok { + writeErr(w, http.StatusNotFound, "no checkpoint cached for this log") + return + } + writeJSON(w, http.StatusOK, bundle) +} + +// handleLogs lists the log ids the relay currently caches — discovery for +// witnesses that countersign everything they can see. +func (rl *Relay) handleLogs(w http.ResponseWriter, r *http.Request) { + rl.mu.Lock() + ids := make([]string, 0, len(rl.logs)) + for id := range rl.logs { + ids = append(ids, id) + } + rl.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"logs": ids}) +} + +// handleFiling fans a filing commitment out to every configured witness +// intake and returns the receipts that came back. The relay holds no intake +// key: an empty intake list yields an empty receipt list, honestly. +func (rl *Relay) handleFiling(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<16)) + if err != nil { + writeErr(w, http.StatusBadRequest, "reading body") + return + } + receipts := make([]json.RawMessage, 0, len(rl.intakes)) + failures := 0 + for _, base := range rl.intakes { + resp, err := rl.client.Post(base+"/v1/filings", "application/json", bytes.NewReader(body)) + if err != nil { + failures++ + continue + } + rb, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + resp.Body.Close() + if err != nil || resp.StatusCode != http.StatusOK { + failures++ + continue + } + receipts = append(receipts, json.RawMessage(rb)) + } + writeJSON(w, http.StatusOK, map[string]any{ + "receipts": receipts, + "intakes": len(rl.intakes), + "failures": failures, + }) +} diff --git a/internal/witnesskit/witnesskit.go b/internal/witnesskit/witnesskit.go new file mode 100644 index 0000000..25b4eff --- /dev/null +++ b/internal/witnesskit/witnesskit.go @@ -0,0 +1,263 @@ +// Package witnesskit is what a MEMBER runs to be a witness — the +// membership-as-witnessing seam (open problem 8's most architecture-shaped +// lever): distribute witness capacity across the membership so a node can be +// governed-honest without being witness-rich. A worker holds one witness +// key, polls a relay (or an operator directly — the relay is convenience, +// not dependency), refuses rollbacks and forks by construction +// (record.Witness's memory), countersigns what extends honestly, and posts +// the countersignature back. It also hosts the filing intake: the one +// witness write. +// +// Running one is deliberately boring: any member, any machine, one key. +// Federation is the plural of this package. +package witnesskit + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/record" + "github.com/NTARI-RAND/Cloudy/internal/relay" +) + +// EncodeCheckpoint builds the relay wire form of a checkpoint. +func EncodeCheckpoint(cp record.Checkpoint, operator ed25519.PublicKey, kind string, from uint64, proof []record.Hash) relay.CheckpointMsg { + msg := relay.CheckpointMsg{ + Log: hex.EncodeToString(cp.Log[:]), + Size: cp.Size, + Head: hex.EncodeToString(cp.Head[:]), + IssuedAt: cp.IssuedAt.UTC().Format(time.RFC3339Nano), + Signature: hex.EncodeToString(cp.Signature), + Operator: hex.EncodeToString(operator), + Kind: kind, + ConsistencyFrom: from, + } + msg.ConsistencyProof = make([]string, len(proof)) + for i, h := range proof { + msg.ConsistencyProof[i] = hex.EncodeToString(h[:]) + } + return msg +} + +func decodeHash(s string) (record.Hash, error) { + var h record.Hash + raw, err := hex.DecodeString(s) + if err != nil || len(raw) != 32 { + return h, errors.New("witnesskit: not a 32-byte hex hash") + } + copy(h[:], raw) + return h, nil +} + +// DecodeCheckpoint parses the wire form, derives the expected log identity +// from the operator key and kind, and REFUSES a message whose log id does +// not match the derivation — a witness never takes the relay's word for +// which log a checkpoint belongs to. +func DecodeCheckpoint(msg relay.CheckpointMsg) (cp record.Checkpoint, operator ed25519.PublicKey, wantLog record.Hash, proof []record.Hash, err error) { + logID, err := decodeHash(msg.Log) + if err != nil { + return cp, nil, wantLog, nil, err + } + head, err := decodeHash(msg.Head) + if err != nil { + return cp, nil, wantLog, nil, err + } + opRaw, err := hex.DecodeString(msg.Operator) + if err != nil || len(opRaw) != ed25519.PublicKeySize { + return cp, nil, wantLog, nil, errors.New("witnesskit: malformed operator key") + } + operator = ed25519.PublicKey(opRaw) + switch msg.Kind { + case "dialog": + wantLog = record.LogID(operator) + case "lifecycle": + wantLog = record.LifecycleLogID(operator) + default: + return cp, nil, wantLog, nil, fmt.Errorf("witnesskit: unknown log kind %q", msg.Kind) + } + if logID != wantLog { + return cp, nil, wantLog, nil, errors.New("witnesskit: log id does not derive from operator key and kind") + } + sig, err := hex.DecodeString(msg.Signature) + if err != nil { + return cp, nil, wantLog, nil, errors.New("witnesskit: malformed signature") + } + issuedAt, err := time.Parse(time.RFC3339Nano, msg.IssuedAt) + if err != nil { + return cp, nil, wantLog, nil, errors.New("witnesskit: malformed issued_at") + } + cp = record.Checkpoint{Log: logID, Size: msg.Size, Head: head, IssuedAt: issuedAt.UTC(), Signature: sig} + proof = make([]record.Hash, 0, len(msg.ConsistencyProof)) + for _, s := range msg.ConsistencyProof { + h, err := decodeHash(s) + if err != nil { + return cp, nil, wantLog, nil, err + } + proof = append(proof, h) + } + return cp, operator, wantLog, proof, nil +} + +// Worker is one member witness: a key, a rollback memory, and a relay to +// poll. Its state is process-volatile (the named amnesia residual: a +// restarted witness reverts to trust-on-first-checkpoint); run it long-lived. +type Worker struct { + witness *record.Witness + intake *record.FilingIntake + pub ed25519.PublicKey + relay string + client *http.Client +} + +// NewWorker returns a worker holding priv, polling relayURL. +func NewWorker(priv ed25519.PrivateKey, relayURL string) *Worker { + w := &Worker{ + witness: record.NewWitness(priv), + intake: record.NewFilingIntake(priv), + relay: relayURL, + client: &http.Client{Timeout: 10 * time.Second}, + } + if len(priv) == ed25519.PrivateKeySize { + w.pub = priv.Public().(ed25519.PublicKey) + } + return w +} + +// Key returns the worker's witness public key. +func (w *Worker) Key() ed25519.PublicKey { return w.pub } + +// RunOnce polls every log the relay knows, countersigns each checkpoint +// that verifies and consistently extends what this witness last cosigned, +// and posts the countersignatures back. It returns how many logs it +// countersigned. Failures on one log never block another. +func (w *Worker) RunOnce() (int, error) { + resp, err := w.client.Get(w.relay + "/v1/logs") + if err != nil { + return 0, err + } + var logs struct { + Logs []string `json:"logs"` + } + err = json.NewDecoder(resp.Body).Decode(&logs) + resp.Body.Close() + if err != nil { + return 0, err + } + signed := 0 + for _, logID := range logs.Logs { + if w.countersignLog(logID) == nil { + signed++ + } + } + return signed, nil +} + +func (w *Worker) countersignLog(logID string) error { + resp, err := w.client.Get(w.relay + "/v1/logs/" + logID + "/checkpoints/latest") + if err != nil { + return err + } + var bundle relay.BundleMsg + err = json.NewDecoder(resp.Body).Decode(&bundle) + resp.Body.Close() + if err != nil { + return err + } + cp, operator, wantLog, proof, err := DecodeCheckpoint(bundle.Checkpoint) + if err != nil { + return err + } + cs, err := w.witness.CountersignAs(cp, operator, wantLog, proof) + if err != nil { + return err + } + msg := relay.CountersigMsg{ + Log: logID, + Size: cp.Size, + Witness: hex.EncodeToString(cs.Witness), + Signature: hex.EncodeToString(cs.Signature), + } + body, err := json.Marshal(msg) + if err != nil { + return err + } + post, err := w.client.Post(w.relay+"/v1/logs/"+logID+"/countersignatures", "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + post.Body.Close() + if post.StatusCode != http.StatusOK { + return fmt.Errorf("witnesskit: relay refused countersignature: %d", post.StatusCode) + } + return nil +} + +// FilingMsg is the wire form of a FilingCommitment. +type FilingMsg struct { + Claim string `json:"claim"` + Exchange string `json:"exchange"` + TypeHash string `json:"type_hash"` + At string `json:"at"` + Filer string `json:"filer"` + Signature string `json:"signature"` +} + +// ReceiptMsg is the wire form of a FilingReceipt. +type ReceiptMsg struct { + Claim string `json:"claim"` + Witness string `json:"witness"` + ReceivedAt string `json:"received_at"` + Signature string `json:"signature"` +} + +// IntakeHandler mounts the one witness write: POST /v1/filings. Everything +// else about a claim happens elsewhere; this endpoint acknowledges +// existence at an instant and nothing more. +func (w *Worker) IntakeHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /v1/filings", func(rw http.ResponseWriter, r *http.Request) { + var msg FilingMsg + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + http.Error(rw, `{"error":"invalid JSON body"}`, http.StatusBadRequest) + return + } + claim, err1 := decodeHash(msg.Claim) + exchange, err2 := decodeHash(msg.Exchange) + typeHash, err3 := decodeHash(msg.TypeHash) + filerRaw, err4 := hex.DecodeString(msg.Filer) + sig, err5 := hex.DecodeString(msg.Signature) + at, err6 := time.Parse(time.RFC3339Nano, msg.At) + if err1 != nil || err2 != nil || err3 != nil || err4 != nil || err5 != nil || err6 != nil || + len(filerRaw) != ed25519.PublicKeySize { + http.Error(rw, `{"error":"malformed filing commitment"}`, http.StatusBadRequest) + return + } + f := record.FilingCommitment{ + Claim: claim, + Exchange: exchange, + TypeHash: typeHash, + At: at.UTC(), + Filer: ed25519.PublicKey(filerRaw), + Signature: sig, + } + receipt, err := w.intake.Accept(f, time.Now().UTC()) + if err != nil { + http.Error(rw, `{"error":"filing refused"}`, http.StatusBadRequest) + return + } + rw.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(rw).Encode(ReceiptMsg{ + Claim: msg.Claim, + Witness: hex.EncodeToString(receipt.Witness), + ReceivedAt: receipt.ReceivedAt.UTC().Format(time.RFC3339Nano), + Signature: hex.EncodeToString(receipt.Signature), + }) + }) + return mux +} From e79109b1eaf5a2462ff5a9f878c473a6c2188d78 Mon Sep 17 00:00:00 2001 From: Jodson Graves Date: Sun, 12 Jul 2026 22:06:56 -0400 Subject: [PATCH 3/3] =?UTF-8?q?feat(covenant):=20typed=20relations=20+=20t?= =?UTF-8?q?he=20answer=20path=20=E2=80=94=20the=20symmetry=20breach=20clos?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two demands the stack plan holds the covenant layer to, in code: - Relations. Every assessment now carries one of three typed relations — trade, adjudication-conduct, verdict-satisfaction — INSIDE its signed bytes (domain bumped to cloudy/covenant/assessment/v1; a trade verdict can never replay as a conduct verdict). Standing is typed all the way down: per-relation distributions with NO cross-relation pool anywhere on the type — collapsing relations is the forbidden average committed across relations, and the shape now makes it inexpressible. Anchoring routes by relation: trade anchors on the sealed exchange as before; the adjudication relations anchor on REAL claim participation (Anchors gains Adjudicated), so the governance-relevant stream cannot be inflated by members who never touched a claim. - Answers. Every claim is answerable: the rated party — the adjudicating operator included, which is the one place the architecture named the symmetry broken — attaches a signed, hash-only annotation to any assessment about them. One answer per assessment; the assessment is never edited, hidden, or diluted (tests pin the harm count unchanged beside its answer). The operator registers as a member of its own platform at construction (single participant identity), making it rateable and answerable through the same public surface as everyone. API: assessments carry relation and return their ID; answers POST/GET at /api/v1/assessments/{id}/answer(s); standing serves the three typed relation views and nothing pooled. End-to-end test drives the loop the architecture demanded: real claim -> conduct No Trust on the operator -> operator's answer -> harm visible in its own stream, stranger's conduct rating refused. Note for review: dispute Withdrawal currently resolves the claim in the lifecycle log; adjudication-conduct remains rateable while a claim dwells (non-response is conduct), which is deliberate. Signed-off-by: Jodson Graves --- cmd/cloudy/main.go | 7 + internal/consumerapi/api_slice2_test.go | 178 ++++++++++++++++++- internal/consumerapi/api_test.go | 6 +- internal/consumerapi/assessments.go | 91 ++++++++++ internal/consumerapi/disputes.go | 1 + internal/consumerapi/members.go | 33 +++- internal/consumerapi/server.go | 46 +++++ internal/covenant/assessment.go | 121 ++++++++++++- internal/covenant/assessment_test.go | 18 +- internal/covenant/book.go | 159 ++++++++++++++--- internal/covenant/book_test.go | 135 +++++++++----- internal/covenant/memstore.go | 40 ++++- internal/covenant/memstore_test.go | 1 + internal/covenant/relation_test.go | 226 ++++++++++++++++++++++++ internal/covenant/standing.go | 73 +++++--- test/composition/composition_test.go | 29 ++- 16 files changed, 1026 insertions(+), 138 deletions(-) create mode 100644 internal/covenant/relation_test.go diff --git a/cmd/cloudy/main.go b/cmd/cloudy/main.go index 3ae76ad..ddd8df6 100644 --- a/cmd/cloudy/main.go +++ b/cmd/cloudy/main.go @@ -122,6 +122,13 @@ func (a *recordAnchors) Sealed(exchange covenant.ExchangeRef, assessor, subject (bytes.Equal(e.Proposer, subjectKey) && bytes.Equal(e.Acceptor, assessorKey)) } +// Adjudicated implements the adjudication-relation anchor. The headless +// composition root has no dispute ingress and no adjudicator, so nothing is +// ever adjudicated here; the honest answer is the constant one. +func (a *recordAnchors) Adjudicated(covenant.ExchangeRef, covenant.MemberID, covenant.MemberID) bool { + return false +} + // disputeAnchors implements dispute.Anchors, the dispute-side twin of // recordAnchors: the same join to the operator's record log on Entry.ID(), but // the dispute port speaks raw ed25519 public keys (not covenant MemberIDs), so diff --git a/internal/consumerapi/api_slice2_test.go b/internal/consumerapi/api_slice2_test.go index d9ee333..73c3099 100644 --- a/internal/consumerapi/api_slice2_test.go +++ b/internal/consumerapi/api_slice2_test.go @@ -154,6 +154,7 @@ func TestAssessmentsAnchorToSealedDialogs(t *testing.T) { Assessor: covenant.MemberIDFor(platform, pubA), Subject: covenant.MemberIDFor(platform, pubB), Exchange: ex, + Relation: covenant.RelationTrade, Category: "reliability", Level: covenant.Level(level), IssuedAt: time.Now().UTC(), @@ -167,6 +168,7 @@ func TestAssessmentsAnchorToSealedDialogs(t *testing.T) { Assessor: string(a.Assessor), Subject: string(a.Subject), Exchange: exchangeHex, + Relation: string(a.Relation), Category: a.Category, Level: level, CommentHash: commentHash, @@ -196,17 +198,26 @@ func TestAssessmentsAnchorToSealedDialogs(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("standing: code %d", rec.Code) } - overall := body["overall"].(map[string]any) + relations := body["relations"].(map[string]any) + trade := relations["trade"].(map[string]any) + overall := trade["overall"].(map[string]any) if overall["total"].(float64) != 1 { - t.Fatalf("standing total = %v, want 1", overall["total"]) + t.Fatalf("trade standing total = %v, want 1", overall["total"]) } counts := overall["counts"].(map[string]any) if counts[covenant.Level(3).String()].(float64) != 1 { t.Fatalf("standing counts = %v, want one at %q", counts, covenant.Level(3).String()) } + // The response is typed by relation and carries NO cross-relation pool + // and no scalar summary anywhere. + for _, rel := range []string{"trade", "adjudication-conduct", "verdict-satisfaction"} { + if _, ok := relations[rel]; !ok { + t.Fatalf("standing response missing relation %q", rel) + } + } for k := range body { - if k == "average" || k == "score" || k == "rating" { - t.Fatalf("standing response leaked a scalar %q — the covenant forbids the average", k) + if k == "overall" || k == "average" || k == "score" || k == "rating" { + t.Fatalf("standing response leaked a cross-relation or scalar field %q", k) } } } @@ -455,3 +466,162 @@ func TestDropProofVerifiesOffline(t *testing.T) { t.Fatal("a tampered entry must not verify") } } + +// TestConductRatingAndAdjudicatorAnswer drives the symmetry loop end to end +// over the API: a member with a real filed claim rates the operator's +// adjudication conduct No Trust; the operator — a member of its own platform +// — answers. The rating stays visible in its own typed stream; the answer +// annotates without erasing anything. +func TestConductRatingAndAdjudicatorAnswer(t *testing.T) { + s, err := NewServer(platform) + if err != nil { + t.Fatal(err) + } + h := s.Handler() + pubA, privA := key(1) + pubB, privB := key(2) + registerMember(t, h, pubA, privA) + registerMember(t, h, pubB, privB) + id := sealExchange(t, h, pubA, privA, pubB, privB) + + // File a real claim (the adjudication-relation anchor). + exRaw, _ := hex.DecodeString(id) + var ex dispute.ExchangeRef + copy(ex[:], exRaw) + reason := sha256.Sum256([]byte("grievance")) + o, err := dispute.NewOpening(platform, pubA, pubB, ex, reason, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if err := o.Sign(privA); err != nil { + t.Fatal(err) + } + oid := o.ID() + typeHash := sha256.Sum256([]byte("trade-harm")) + commitment := record.FilingCommitment{ + Claim: record.Hash(oid), + Exchange: record.Hash(ex), + TypeHash: record.Hash(typeHash), + At: o.OpenedAt, + Filer: pubA, + } + commitment.Sign(privA) + req := openDisputeRequest{ + Complainant: hex.EncodeToString(pubA), + Respondent: hex.EncodeToString(pubB), + Exchange: id, + ReasonHash: hex.EncodeToString(o.ReasonHash[:]), + Nonce: hex.EncodeToString(o.Nonce[:]), + OpenedAt: o.OpenedAt.Format(time.RFC3339Nano), + Signature: hex.EncodeToString(o.Signature), + } + req.Filing.TypeHash = hex.EncodeToString(typeHash[:]) + req.Filing.At = o.OpenedAt.Format(time.RFC3339Nano) + req.Filing.Signature = hex.EncodeToString(commitment.Signature) + rec, _ := do(t, h, "POST", "/api/v1/disputes", req) + if rec.Code != http.StatusOK { + t.Fatalf("open dispute: %d %s", rec.Code, rec.Body.String()) + } + + // The complainant rates the OPERATOR's conduct: No Trust, with the + // mandatory comment digest. The operator's member id is the subject. + operatorMember := s.operatorMember + commentDigest := sha256.Sum256([]byte("sat on my claim")) + a := covenant.Assessment{ + Assessor: covenant.MemberIDFor(platform, pubA), + Subject: operatorMember, + Exchange: covenant.ExchangeRef(record.Hash(ex)), + Relation: covenant.RelationAdjudicationConduct, + Category: "support", + Level: covenant.LevelNoTrust, + CommentHash: commentDigest, + IssuedAt: time.Now().UTC(), + } + a.Sign(privA) + rec, body := do(t, h, "POST", "/api/v1/assessments", assessmentRequest{ + Assessor: string(a.Assessor), + Subject: string(a.Subject), + Exchange: id, + Relation: string(a.Relation), + Category: a.Category, + Level: int8(a.Level), + CommentHash: hex.EncodeToString(commentDigest[:]), + IssuedAt: a.IssuedAt.Format(time.RFC3339Nano), + Signature: hex.EncodeToString(a.Signature), + }) + if rec.Code != http.StatusOK { + t.Fatalf("conduct assessment: %d %s", rec.Code, rec.Body.String()) + } + assessmentID := body["id"].(string) + + // A member with NO claim on the exchange cannot rate conduct (Sybil + // posture: the governance-relevant stream is not inflatable). The + // respondent could — they are a genuine party — so the negative case is + // a THIRD member who never touched the claim. + pubC, privC := key(3) + registerMember(t, h, pubC, privC) + b := covenant.Assessment{ + Assessor: covenant.MemberIDFor(platform, pubC), + Subject: operatorMember, + Exchange: covenant.ExchangeRef(record.Hash(ex)), + Relation: covenant.RelationAdjudicationConduct, + Category: "support", + Level: covenant.LevelBasicPromise, + IssuedAt: time.Now().UTC(), + } + b.Sign(privC) + rec, _ = do(t, h, "POST", "/api/v1/assessments", assessmentRequest{ + Assessor: string(b.Assessor), + Subject: string(b.Subject), + Exchange: id, + Relation: string(b.Relation), + Category: b.Category, + Level: int8(b.Level), + IssuedAt: b.IssuedAt.Format(time.RFC3339Nano), + Signature: hex.EncodeToString(b.Signature), + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("a stranger's conduct rating must be refused (got %d) — this stream is Sybil food otherwise", rec.Code) + } + + // The operator answers through the same public surface — the recourse + // the architecture demanded. It signs with its own key client-side; the + // test reaches into the server only for the key, exactly like a real + // operator would hold its own. + answerDigest := sha256.Sum256([]byte("adjudication timeline, member-local")) + an := covenant.Answer{ + Answerer: operatorMember, + AnswerHash: answerDigest, + IssuedAt: time.Now().UTC(), + } + idRaw, _ := hex.DecodeString(assessmentID) + copy(an.Assessment[:], idRaw) + an.Sign(s.operatorPriv) + rec, _ = do(t, h, "POST", "/api/v1/assessments/"+assessmentID+"/answers", answerRequest{ + Answerer: string(operatorMember), + AnswerHash: hex.EncodeToString(answerDigest[:]), + IssuedAt: an.IssuedAt.Format(time.RFC3339Nano), + Signature: hex.EncodeToString(an.Signature), + }) + if rec.Code != http.StatusOK { + t.Fatalf("adjudicator answer: %d %s", rec.Code, rec.Body.String()) + } + rec, body = do(t, h, "GET", "/api/v1/assessments/"+assessmentID+"/answer", nil) + if rec.Code != http.StatusOK || body["answerer"] != string(operatorMember) { + t.Fatalf("read answer: %d %v", rec.Code, body) + } + + // The harm stays visible in its own typed stream beside the answer. + rec, body = do(t, h, "GET", "/api/v1/members/"+string(operatorMember)+"/standing", nil) + if rec.Code != http.StatusOK { + t.Fatalf("standing: %d", rec.Code) + } + conduct := body["relations"].(map[string]any)["adjudication-conduct"].(map[string]any) + if conduct["harm"].(float64) != 1 { + t.Fatal("the conduct harm must stay visible; an answer annotates, never erases") + } + trade := body["relations"].(map[string]any)["trade"].(map[string]any) + if trade["harm"].(float64) != 0 { + t.Fatal("the conduct harm must not leak into the trade stream") + } +} diff --git a/internal/consumerapi/api_test.go b/internal/consumerapi/api_test.go index 32bfb95..7a1491e 100644 --- a/internal/consumerapi/api_test.go +++ b/internal/consumerapi/api_test.go @@ -197,8 +197,10 @@ func TestFullFlow(t *testing.T) { if srec.Code != http.StatusOK { t.Fatalf("standing: %d %s", srec.Code, srec.Body.String()) } - if sout["harm"].(float64) != 0 { - t.Fatalf("fresh member harm = %v, want 0", sout["harm"]) + for rel, v := range sout["relations"].(map[string]any) { + if v.(map[string]any)["harm"].(float64) != 0 { + t.Fatalf("fresh member harm (%s) = %v, want 0", rel, v.(map[string]any)["harm"]) + } } } diff --git a/internal/consumerapi/assessments.go b/internal/consumerapi/assessments.go index 919b7fc..88e5003 100644 --- a/internal/consumerapi/assessments.go +++ b/internal/consumerapi/assessments.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "time" "github.com/NTARI-RAND/Cloudy/internal/covenant" ) @@ -12,6 +13,7 @@ type assessmentRequest struct { Assessor string `json:"assessor"` // MemberID of the assessing member; their key signs Subject string `json:"subject"` // MemberID whose standing this shapes Exchange string `json:"exchange"` // hex leaf ID of the sealed dialog both took part in + Relation string `json:"relation"` // trade | adjudication-conduct | verdict-satisfaction Category string `json:"category"` // one of the Book's closed vocabulary Level int8 `json:"level"` // LBTAS level, -1 (No Trust) .. +4 CommentHash string `json:"comment_hash,omitempty"` // hex SHA-256 of the member-local justification; REQUIRED at No Trust @@ -62,6 +64,7 @@ func (s *Server) handleRecordAssessment(w http.ResponseWriter, r *http.Request) Assessor: covenant.MemberID(req.Assessor), Subject: covenant.MemberID(req.Subject), Exchange: covenant.ExchangeRef(exchange), + Relation: covenant.Relation(req.Relation), Category: req.Category, Level: covenant.Level(req.Level), CommentHash: commentHash, @@ -72,11 +75,99 @@ func (s *Server) handleRecordAssessment(w http.ResponseWriter, r *http.Request) err := s.book.Record(a) s.mu.Unlock() switch { + case err == nil: + id := a.ID() + writeJSON(w, http.StatusOK, map[string]string{"status": "recorded", "id": hx(id[:])}) + case errors.Is(err, covenant.ErrDuplicate): + writeErr(w, http.StatusConflict, err.Error()) + default: + writeErr(w, http.StatusBadRequest, err.Error()) + } +} + +type answerRequest struct { + Answerer string `json:"answerer"` // MemberID of the rated party; their key signs + AnswerHash string `json:"answer_hash"` // hex SHA-256 of the member-local response text + IssuedAt string `json:"issued_at"` + Signature string `json:"signature"` // hex ed25519 by the answerer +} + +// handleAnswerAssessment admits the rated party's signed answer to an +// assessment about them — the covenant's symmetry made operational: every +// claim is answerable, for every relation, adjudication-conduct included +// (the adjudicator answers exactly like anyone else). The answer annotates; +// the assessment it answers is never edited or hidden. +func (s *Server) handleAnswerAssessment(w http.ResponseWriter, r *http.Request) { + id, ok := decodeHex32(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusBadRequest, "id must be a 32-byte hex assessment ID") + return + } + var req answerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + answerHash, ok := decodeHex32(req.AnswerHash) + if !ok { + writeErr(w, http.StatusBadRequest, "answer_hash must be a 32-byte hex digest") + return + } + issuedAt, ok := parseUTC(req.IssuedAt) + if !ok { + writeErr(w, http.StatusBadRequest, "issued_at must be RFC3339") + return + } + sig, ok := decodeSig(req.Signature) + if !ok { + writeErr(w, http.StatusBadRequest, "signature must be a 64-byte hex signature") + return + } + an := covenant.Answer{ + Assessment: id, + Answerer: covenant.MemberID(req.Answerer), + AnswerHash: answerHash, + IssuedAt: issuedAt, + Signature: sig, + } + s.mu.Lock() + err := s.book.RecordAnswer(an) + s.mu.Unlock() + switch { case err == nil: writeJSON(w, http.StatusOK, map[string]string{"status": "recorded"}) case errors.Is(err, covenant.ErrDuplicate): writeErr(w, http.StatusConflict, err.Error()) + case errors.Is(err, covenant.ErrUnknownAssessment): + writeErr(w, http.StatusNotFound, err.Error()) default: writeErr(w, http.StatusBadRequest, err.Error()) } } + +// handleGetAnswer serves the answer to an assessment, if one exists. +func (s *Server) handleGetAnswer(w http.ResponseWriter, r *http.Request) { + id, ok := decodeHex32(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusBadRequest, "id must be a 32-byte hex assessment ID") + return + } + s.mu.Lock() + an, found, err := s.book.AnswerFor(id) + s.mu.Unlock() + if err != nil { + writeErr(w, http.StatusInternalServerError, "reading answer") + return + } + if !found { + writeErr(w, http.StatusNotFound, "no answer for this assessment") + return + } + writeJSON(w, http.StatusOK, map[string]string{ + "assessment": r.PathValue("id"), + "answerer": string(an.Answerer), + "answer_hash": hx(an.AnswerHash[:]), + "issued_at": an.IssuedAt.UTC().Format(time.RFC3339Nano), + "signature": hx(an.Signature), + }) +} diff --git a/internal/consumerapi/disputes.go b/internal/consumerapi/disputes.go index 3232b9f..e3e7eb7 100644 --- a/internal/consumerapi/disputes.go +++ b/internal/consumerapi/disputes.go @@ -144,6 +144,7 @@ func (s *Server) handleOpenDispute(w http.ResponseWriter, r *http.Request) { s.mu.Lock() id, err := s.registry.Open(o) if err == nil { + s.disputesByExchange[record.Hash(exchange)] = append(s.disputesByExchange[record.Hash(exchange)], id) artifact := sha256.Sum256(o.CanonicalBytes()) _, err = s.lifeLog.Append(record.Transition{ Log: s.lifeID, diff --git a/internal/consumerapi/members.go b/internal/consumerapi/members.go index 54d00e1..a4697b3 100644 --- a/internal/consumerapi/members.go +++ b/internal/consumerapi/members.go @@ -69,13 +69,21 @@ type distributionDTO struct { Total int `json:"total"` } -type standingResponse struct { - MemberID string `json:"member_id"` +type relationStandingDTO struct { Overall distributionDTO `json:"overall"` ByCategory map[string]distributionDTO `json:"by_category"` Harm int `json:"harm"` // count of No Trust (-1); surfaced by name, never diluted } +type standingResponse struct { + MemberID string `json:"member_id"` + // Relations types the standing: trade, adjudication-conduct, and + // verdict-satisfaction are different relations with different base + // rates, and this response deliberately provides NO cross-relation pool + // — collapsing them would be the average committed across relations. + Relations map[string]relationStandingDTO `json:"relations"` +} + func distToDTO(d covenant.Distribution) distributionDTO { counts := map[string]int{} for _, lvl := range covenant.Levels() { @@ -100,15 +108,22 @@ func (s *Server) handleStanding(w http.ResponseWriter, r *http.Request) { return } - byCat := map[string]distributionDTO{} - for _, name := range categories { - byCat[name] = distToDTO(standing.Category(name)) + relations := map[string]relationStandingDTO{} + for _, rel := range covenant.Relations() { + rs := standing.Relation(rel) + byCat := map[string]distributionDTO{} + for _, name := range categories { + byCat[name] = distToDTO(rs.Category(name)) + } + relations[string(rel)] = relationStandingDTO{ + Overall: distToDTO(rs.Overall()), + ByCategory: byCat, + Harm: rs.Harm(), + } } writeJSON(w, http.StatusOK, standingResponse{ - MemberID: string(member), - Overall: distToDTO(standing.Overall()), - ByCategory: byCat, - Harm: standing.Harm(), + MemberID: string(member), + Relations: relations, }) } diff --git a/internal/consumerapi/server.go b/internal/consumerapi/server.go index 5811017..d27c937 100644 --- a/internal/consumerapi/server.go +++ b/internal/consumerapi/server.go @@ -100,6 +100,14 @@ type Server struct { lifeID record.Hash intake *record.FilingIntake + // operatorMember is the operator's own MemberID: the operator registers + // in its own directory at construction (single participant identity — + // the adjudicating operator is a member like any other, answerable + // through the same covenant). disputesByExchange indexes claims by the + // exchange they dispute, for the adjudication-relation anchor. + operatorMember covenant.MemberID + disputesByExchange map[record.Hash][]dispute.DisputeID + // manifest maps a commons artifact id (hex) to the Locker hashes of its // member-local narrative — the front-end-local index that lets a reader // fetch narrative the commons deliberately does not carry. @@ -168,6 +176,33 @@ func (a recAnchors) Sealed(exchange covenant.ExchangeRef, assessor, subject cove (bytes.Equal(e.Proposer, subjectKey) && bytes.Equal(e.Acceptor, assessorKey)) } +// Adjudicated implements the adjudication-relation anchor: the assessor was +// a genuine party (complainant or respondent) to a claim on this exchange, +// and the subject is the adjudicating operator's own MemberID. Callers hold +// s.mu. Sybil posture: adjudication-conduct and verdict-satisfaction +// standing can only accumulate from members with real, anchored claims — +// there is nothing here for a bot swarm to inflate. +func (a recAnchors) Adjudicated(exchange covenant.ExchangeRef, assessor, subject covenant.MemberID) bool { + if subject != a.s.operatorMember { + return false + } + assessorKey, ok := a.s.byMember[assessor] + if !ok { + return false + } + ids := a.s.disputesByExchange[record.Hash(exchange)] + for _, id := range ids { + c, err := a.s.registry.Case(id) + if err != nil { + continue + } + if bytes.Equal(c.Complainant(), assessorKey) || bytes.Equal(c.Respondent(), assessorKey) { + return true + } + } + return false +} + // dispAnchors is the dispute-side twin: the same join on the same index, but // the dispute port speaks raw ed25519 keys, so the party match compares keys // directly. The [32]byte conversion between record.Hash and the two layers' @@ -279,6 +314,15 @@ func NewServer(platform string) (*Server, error) { } s.lifeID = record.LifecycleLogID(operatorPub) s.intake = record.NewFilingIntake(operatorPriv) + // The operator registers as a member of its own platform: one identity, + // contributor and consumer and (here) adjudicator at once — and thereby + // RATEABLE: adjudication-conduct and verdict-satisfaction assessments + // name this MemberID as their subject, and it can answer them. + s.operatorMember = covenant.MemberIDFor(platform, operatorPub) + owned := append(ed25519.PublicKey(nil), operatorPub...) + s.byMember[s.operatorMember] = owned + s.byAccount[economy.AccountIDFor(platform, operatorPub)] = owned + s.disputesByExchange = make(map[record.Hash][]dispute.DisputeID) return s, nil } @@ -303,6 +347,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/v1/credit/accounts/{id}/history", s.handleHistory) mux.HandleFunc("POST /api/v1/credit/spends", s.handlePostSpend) mux.HandleFunc("POST /api/v1/assessments", s.handleRecordAssessment) + mux.HandleFunc("POST /api/v1/assessments/{id}/answers", s.handleAnswerAssessment) + mux.HandleFunc("GET /api/v1/assessments/{id}/answer", s.handleGetAnswer) mux.HandleFunc("POST /api/v1/disputes", s.handleOpenDispute) mux.HandleFunc("GET /api/v1/disputes/{id}", s.handleGetDispute) mux.HandleFunc("POST /api/v1/disputes/{id}/withdraw", s.handleWithdrawDispute) diff --git a/internal/covenant/assessment.go b/internal/covenant/assessment.go index cb96523..ec7ee32 100644 --- a/internal/covenant/assessment.go +++ b/internal/covenant/assessment.go @@ -15,7 +15,15 @@ import ( // tag per message, per canon's domain-separation rule: a covenant signature // is not transferable to any other message type or platform tag. v0 is // unstable — the byte layout may change without compatibility guarantees. -const domainAssessment = "cloudy/covenant/assessment/v0" +const domainAssessment = "cloudy/covenant/assessment/v1" + +// domainAssessmentID tags the derivation of an assessment's identity — the +// hash other artifacts (answers) reference. Distinct from the signing tag: +// an ID is a hash role, never a signature role. +const domainAssessmentID = "cloudy/covenant/assessment-id/v1" + +// domainAnswer tags the canonical signing payload of an Answer. +const domainAnswer = "cloudy/covenant/answer/v1" // domainMember tags the hash derivation of a MemberID from a platform name // and a member public key. Distinct from the assessment tag — a tag is never @@ -130,15 +138,56 @@ func validLevel(l Level) bool { return false } +// Relation types a verdict by the relationship it rates. Trade, +// adjudication-conduct, and verdict-satisfaction are different relations +// with different base rates; the record MUST distinguish them, and no reader +// may collapse them into one figure — that is the average the covenant +// forbids, committed across relations instead of across ratings +// (architecture, Record invariants). The vocabulary is closed: a relation +// outside these three is rejected at Record. +type Relation string + +const ( + // RelationTrade rates a counterparty's honoring of a sealed exchange — + // the covenant's original and highest-volume relation. + RelationTrade Relation = "trade" + // RelationAdjudicationConduct rates how the adjudicating operator DID + // ITS JOB on a claim the assessor was party to — responsiveness, + // process, dwell. This is the governance-relevant stream: it is where + // an operator can abuse the very users it also gates. + RelationAdjudicationConduct Relation = "adjudication-conduct" + // RelationVerdictSatisfaction rates a party's satisfaction with a + // judgment. Unsuppressable, and therefore honest — but a No Trust here + // is a losing party's displeasure, NOT operator misconduct; readers + // must never conflate this stream with adjudication-conduct. + RelationVerdictSatisfaction Relation = "verdict-satisfaction" +) + +// validRelation reports whether r is one of the three covenant relations. +func validRelation(r Relation) bool { + switch r { + case RelationTrade, RelationAdjudicationConduct, RelationVerdictSatisfaction: + return true + } + return false +} + +// Relations returns the three relations in a stable display order. +func Relations() [3]Relation { + return [3]Relation{RelationTrade, RelationAdjudicationConduct, RelationVerdictSatisfaction} +} + // Assessment is one member's signed verdict on one sealed exchange it took -// part in, under one category of the Book's closed vocabulary. Its field set -// is closed — no free text, no note, no metadata map — so no PII or narrative -// conduit exists in the covenant record: Category is validated against a -// closed set at Record, and CommentHash is 32 opaque bytes, never text. +// part in, under one category of the Book's closed vocabulary and one of the +// three typed relations. Its field set is closed — no free text, no note, no +// metadata map — so no PII or narrative conduit exists in the covenant +// record: Category and Relation are validated against closed sets at Record, +// and CommentHash is 32 opaque bytes, never text. type Assessment struct { Assessor MemberID // who renders the verdict; must differ from Subject Subject MemberID // whose standing it shapes Exchange ExchangeRef // sealed record entry this verdict is grounded in; zero is invalid + Relation Relation // trade | adjudication-conduct | verdict-satisfaction; typed, never collapsed Category string // one of the Book's closed category vocabulary; free text is rejected Level Level // one of the six LBTAS levels CommentHash [32]byte // SHA-256 of the justifying comment; MUST be non-zero when Level is LevelNoTrust, MAY be zero otherwise @@ -147,16 +196,19 @@ type Assessment struct { } // CanonicalBytes returns the deterministic signing payload (canon encoder, -// domain tag "cloudy/covenant/assessment/v0") with Signature excluded; it is +// domain tag "cloudy/covenant/assessment/v1") with Signature excluded; it is // a signing payload only, never an export or interchange format. Field order -// is fixed and documented: assessor, subject, exchange, category, level -// (fixed 8-byte big-endian two's-complement Int64 of the LBTAS numeric -// value), commentHash, issuedAt. +// is fixed and documented: assessor, subject, exchange, relation, category, +// level (fixed 8-byte big-endian two's-complement Int64 of the LBTAS numeric +// value), commentHash, issuedAt. v1 adds relation inside the signed bytes — +// an assessor's signature binds the relation it rated, so a trade verdict +// can never be replayed as a conduct verdict. func (a Assessment) CanonicalBytes() []byte { b := canon.New(domainAssessment) b.String(string(a.Assessor)) b.String(string(a.Subject)) b.Bytes(a.Exchange[:]) + b.String(string(a.Relation)) b.String(a.Category) b.Int64(int64(a.Level)) b.Bytes(a.CommentHash[:]) @@ -164,6 +216,16 @@ func (a Assessment) CanonicalBytes() []byte { return b.Sum() } +// ID returns the assessment's identity: the SHA-256, under its own domain +// tag, of the canonical bytes plus the signature — the value an Answer +// references. Signature-inclusive, so an unsigned draft has no citable ID. +func (a Assessment) ID() [32]byte { + b := canon.New(domainAssessmentID) + b.Bytes(a.CanonicalBytes()) + b.Bytes(a.Signature) + return sha256.Sum256(b.Sum()) +} + // Sign sets Signature using the assessor's private key. func (a *Assessment) Sign(priv ed25519.PrivateKey) { a.Signature = ed25519.Sign(priv, a.CanonicalBytes()) @@ -191,3 +253,44 @@ var ErrInvalid = errors.New("covenant: invalid assessment") // exchange under the same category; one exchange grounds at most one verdict // per (assessor, category), forever. var ErrDuplicate = errors.New("covenant: assessor already assessed this exchange under this category") + +// Answer is the rated party's signed response to an assessment about them — +// the mechanism that keeps the covenant symmetric: every claim is +// answerable, an answer is an annotation that never erases or edits the +// assessment it answers, and the one place the architecture named the +// symmetry broken (an adjudicator rated without recourse) is closed by this +// artifact existing for every relation, adjudication-conduct included. Like +// the assessment, its field set is closed: the response text lives in +// erasable member-local storage; the commons carries only its digest. +type Answer struct { + Assessment [32]byte // Assessment.ID() of the verdict being answered + Answerer MemberID // must be the assessment's Subject — only the rated party answers + AnswerHash [32]byte // SHA-256 of the member-local response text; non-zero + IssuedAt time.Time // UTC + Signature []byte // ed25519 by the Answerer; excluded from CanonicalBytes +} + +// CanonicalBytes returns the deterministic signing payload for the answer. +func (an Answer) CanonicalBytes() []byte { + b := canon.New(domainAnswer) + b.Bytes(an.Assessment[:]) + b.String(string(an.Answerer)) + b.Bytes(an.AnswerHash[:]) + b.Time(an.IssuedAt) + return b.Sum() +} + +// Sign sets Signature using the answerer's private key. +func (an *Answer) Sign(priv ed25519.PrivateKey) { + an.Signature = ed25519.Sign(priv, an.CanonicalBytes()) +} + +// Verify reports whether Signature is a valid answerer signature. +func (an Answer) Verify(pub ed25519.PublicKey) bool { + return len(an.Signature) == ed25519.SignatureSize && + ed25519.Verify(pub, an.CanonicalBytes(), an.Signature) +} + +// ErrUnknownAssessment is returned when an answer references no admitted +// assessment in this Book's store. +var ErrUnknownAssessment = errors.New("covenant: answer references no admitted assessment") diff --git a/internal/covenant/assessment_test.go b/internal/covenant/assessment_test.go index e6bc009..ad327ce 100644 --- a/internal/covenant/assessment_test.go +++ b/internal/covenant/assessment_test.go @@ -67,6 +67,7 @@ func testAssessment(assessor, subject MemberID, ex ExchangeRef, l Level) Assessm Assessor: assessor, Subject: subject, Exchange: ex, + Relation: RelationTrade, Category: testCategory, Level: l, IssuedAt: time.Unix(1700000000, 0).UTC(), @@ -104,6 +105,12 @@ func (ss sealSet) Sealed(ex ExchangeRef, assessor, subject MemberID) bool { return ok } +// Adjudicated implements the adjudication-relation anchor for tests that +// exercise trade only; relation-specific anchoring has its own fake below. +func (ss sealSet) Adjudicated(ExchangeRef, MemberID, MemberID) bool { + return false +} + // --- level tests ----------------------------------------------------------- func TestLevelValues(t *testing.T) { @@ -241,6 +248,7 @@ func goldenAssessment() Assessment { a := Assessment{ Assessor: MemberID(strings.Repeat("0123456789abcdef", 4)), Subject: MemberID(strings.Repeat("fedcba9876543210", 4)), + Relation: RelationTrade, Category: "reliability", Level: LevelBasicSatisfaction, IssuedAt: time.Unix(1700000000, 123456789).UTC(), @@ -276,13 +284,14 @@ func appendInt64(b []byte, v int64) []byte { // reconstructCanonical rebuilds an assessment's canonical bytes independently // of canon, in the documented field order: assessor, subject, exchange, -// category, level (Int64), commentHash, issuedAt. +// relation, category, level (Int64), commentHash, issuedAt. func reconstructCanonical(a Assessment) []byte { var want []byte - want = appendLenPrefixed(want, []byte("cloudy/covenant/assessment/v0")) + want = appendLenPrefixed(want, []byte("cloudy/covenant/assessment/v1")) want = appendLenPrefixed(want, []byte(a.Assessor)) want = appendLenPrefixed(want, []byte(a.Subject)) want = appendLenPrefixed(want, a.Exchange[:]) + want = appendLenPrefixed(want, []byte(a.Relation)) want = appendLenPrefixed(want, []byte(a.Category)) want = appendInt64(want, int64(a.Level)) want = appendLenPrefixed(want, a.CommentHash[:]) @@ -302,10 +311,11 @@ func TestCanonicalBytesGolden(t *testing.T) { // Frozen hex of the same vector: fails on ANY change to fields, order, // tag, or encoding — including a change mirrored into the reconstruction. const goldenHex = "" + - "1d636c6f7564792f636f76656e616e742f6173736573736d656e742f7630" + + "1d636c6f7564792f636f76656e616e742f6173736573736d656e742f7631" + "4030313233343536373839616263646566303132333435363738396162636465663031323334353637383961626364656630313233343536373839616263646566" + "4066656463626139383736353433323130666564636261393837363534333231306665646362613938373635343332313066656463626139383736353433323130" + "200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + + "057472616465" + "0b72656c696162696c697479" + "0000000000000002" + "20c0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedf" + @@ -315,7 +325,7 @@ func TestCanonicalBytesGolden(t *testing.T) { } // The bytes begin with the length-prefixed domain tag. - tag := "cloudy/covenant/assessment/v0" + tag := "cloudy/covenant/assessment/v1" prefix := appendLenPrefixed(nil, []byte(tag)) if !bytes.HasPrefix(got, prefix) { t.Errorf("canonical bytes must begin with the length-prefixed domain tag %q", tag) diff --git a/internal/covenant/book.go b/internal/covenant/book.go index 2269b18..34ee712 100644 --- a/internal/covenant/book.go +++ b/internal/covenant/book.go @@ -23,6 +23,13 @@ type Directory interface { type Anchors interface { // Sealed reports whether exchange is a sealed entry between assessor and subject. Sealed(exchange ExchangeRef, assessor, subject MemberID) bool + // Adjudicated reports whether assessor was a genuine party to a claim on + // this exchange that subject adjudicated (or is adjudicating) — the + // anchor for the adjudication-conduct and verdict-satisfaction + // relations. Grounding these on real claim participation is what keeps + // the governance-relevant streams un-inflatable: a member who was never + // party to a claim has no standing to rate its handling. + Adjudicated(exchange ExchangeRef, assessor, subject MemberID) bool } // Admitted is an assessment the Book has verified and admitted. It cannot be @@ -45,20 +52,48 @@ func (ad Admitted) Assessment() Assessment { return a } +// AdmittedAnswer is an answer the Book has verified and admitted; like +// Admitted it cannot be constructed outside this package, so a Store can +// hold answers but never mint one. +type AdmittedAnswer struct { + an Answer +} + +// Answer returns the admitted answer with a defensive signature copy. +func (aa AdmittedAnswer) Answer() Answer { + an := aa.an + if an.Signature != nil { + sig := make([]byte, len(an.Signature)) + copy(sig, an.Signature) + an.Signature = sig + } + return an +} + // Store is the persistence port. It is append-only by construction — no -// update, no delete — and trades only in Admitted. Implementations MUST -// reject a second value with the same (Assessor, Exchange, Category) with -// ErrDuplicate, atomically under concurrent Appends; MUST reject the zero -// Admitted (detectable by Assessment().Assessor == "") with ErrInvalid; and -// MUST return defensive copies from BySubject. +// update, no delete — and trades only in Admitted and AdmittedAnswer. +// Implementations MUST reject a second assessment with the same (Assessor, +// Exchange, Relation, Category) with ErrDuplicate, atomically under +// concurrent Appends; MUST reject the zero Admitted (detectable by +// Assessment().Assessor == "") with ErrInvalid; MUST reject a second answer +// to the same assessment with ErrDuplicate (an answer annotates once; it is +// never edited); and MUST return defensive copies everywhere. type Store interface { // Append durably records ad, or returns ErrDuplicate if an admitted - // assessment already exists for (ad.Assessment().Assessor, - // ad.Assessment().Exchange, ad.Assessment().Category). + // assessment already exists for (Assessor, Exchange, Relation, Category). Append(ad Admitted) error // BySubject returns every admitted assessment whose Subject is subject, // in append order; an unknown subject yields an empty slice, not an error. BySubject(subject MemberID) ([]Admitted, error) + // ByID returns the admitted assessment with the given Assessment.ID(), + // or ok=false when the store has never admitted it. + ByID(id [32]byte) (Admitted, bool, error) + // AppendAnswer durably records aa, or returns ErrDuplicate if the + // referenced assessment already has an answer. + AppendAnswer(aa AdmittedAnswer) error + // AnswerFor returns the admitted answer for the assessment id, or + // ok=false when it has none. + AnswerFor(id [32]byte) (AdmittedAnswer, bool, error) } // lbtasDefaultCategories returns a fresh copy of the LBTAS default category @@ -149,6 +184,9 @@ func (b *Book) Record(a Assessment) error { if a.Exchange == (ExchangeRef{}) { return fmt.Errorf("%w: zero exchange reference", ErrInvalid) } + if !validRelation(a.Relation) { + return fmt.Errorf("%w: relation %q is not one of the covenant's three typed relations", ErrInvalid, string(a.Relation)) + } if !validLevel(a.Level) { return fmt.Errorf("%w: level %d is not one of the six LBTAS levels (-1..+4)", ErrInvalid, int8(a.Level)) } @@ -188,8 +226,18 @@ func (b *Book) Record(a Assessment) error { if MemberIDFor(b.platform, subjectPub) != a.Subject { return fmt.Errorf("%w: directory key does not mint the subject's member ID", ErrInvalid) } - if !b.anchors.Sealed(a.Exchange, a.Assessor, a.Subject) { - return fmt.Errorf("%w: exchange is not sealed between these two members", ErrInvalid) + switch a.Relation { + case RelationTrade: + if !b.anchors.Sealed(a.Exchange, a.Assessor, a.Subject) { + return fmt.Errorf("%w: exchange is not sealed between these two members", ErrInvalid) + } + default: + // adjudication-conduct and verdict-satisfaction anchor on real claim + // participation: the assessor was a party to a claim on this + // exchange, and the subject is its adjudicator. + if !b.anchors.Adjudicated(a.Exchange, a.Assessor, a.Subject) { + return fmt.Errorf("%w: no adjudicated claim on this exchange binds this assessor to this adjudicator", ErrInvalid) + } } sig := make([]byte, len(a.Signature)) copy(sig, a.Signature) @@ -197,11 +245,13 @@ func (b *Book) Record(a Assessment) error { return b.store.Append(Admitted{a: a}) } -// Standing returns subject's full standing in the LBTAS read shape: the -// lossless per-level count histogram of every admitted assessment about -// them, per category and pooled overall, with the No Trust count surfaced by -// Harm. A never-assessed member yields an empty Standing with Total zero, -// not an error. Nothing here — or anywhere in the package — returns a mean, +// Standing returns subject's full standing in the LBTAS read shape, TYPED +// BY RELATION: for each of the three relations, the lossless per-level count +// histogram per category and pooled within that relation, with the No Trust +// count surfaced by Harm. There is deliberately no cross-relation pool: an +// operator's verdict-satisfaction ratings never blur into its +// adjudication-conduct stream, and neither blurs into trade. A +// never-assessed member yields an empty Standing, not an error. Nothing here — or anywhere in the package — returns a mean, // average, or scalar summary of level values. // // Standing does not trust the Store's indexing — the signed data is the @@ -221,10 +271,7 @@ func (b *Book) Standing(subject MemberID) (Standing, error) { if err != nil { return Standing{}, err } - s := Standing{ - byCategory: make(map[string]Distribution, len(b.categories)), - overall: Distribution{counts: make(map[Level]int, 6)}, - } + s := Standing{perRelation: make(map[Relation]RelationStanding, 3)} seen := make(map[string]struct{}, len(ads)) for _, ad := range ads { a := ad.Assessment() @@ -233,24 +280,90 @@ func (b *Book) Standing(subject MemberID) (Standing, error) { "covenant: Standing: store contract violation: BySubject(%q) returned an assessment about %q", subject, a.Subject) } - key := string(a.Assessor) + "\x00" + string(a.Exchange[:]) + "\x00" + a.Category + key := string(a.Assessor) + "\x00" + string(a.Exchange[:]) + "\x00" + string(a.Relation) + "\x00" + a.Category if _, dup := seen[key]; dup { continue } seen[key] = struct{}{} - d := s.byCategory[a.Category] + rs, ok := s.perRelation[a.Relation] + if !ok { + rs = RelationStanding{ + byCategory: make(map[string]Distribution, len(b.categories)), + overall: Distribution{counts: make(map[Level]int, 6)}, + } + } + d := rs.byCategory[a.Category] if d.counts == nil { d.counts = make(map[Level]int, 6) } d.counts[a.Level]++ d.total++ - s.byCategory[a.Category] = d - s.overall.counts[a.Level]++ - s.overall.total++ + rs.byCategory[a.Category] = d + rs.overall.counts[a.Level]++ + rs.overall.total++ + s.perRelation[a.Relation] = rs } return s, nil } +// RecordAnswer validates an answer, verifies it references an admitted +// assessment, that the answerer IS that assessment's Subject (only the +// rated party answers), verifies the key binding and signature exactly as +// Record does for assessments, and appends it. The answered assessment is +// untouched: an answer annotates, never edits — a dismissal is a new +// visible annotation, never an erasure. +func (b *Book) RecordAnswer(an Answer) error { + if an.Assessment == ([32]byte{}) { + return fmt.Errorf("%w: answer references no assessment", ErrInvalid) + } + if !validMemberID(an.Answerer) { + return fmt.Errorf("%w: answerer is not a minted member ID (must be exactly 64 lowercase-hex characters)", ErrInvalid) + } + if an.AnswerHash == ([32]byte{}) { + return fmt.Errorf("%w: an answer requires a non-zero AnswerHash over its member-local response", ErrInvalid) + } + if an.IssuedAt.IsZero() { + return fmt.Errorf("%w: zero IssuedAt", ErrInvalid) + } + ad, ok, err := b.store.ByID(an.Assessment) + if err != nil { + return err + } + if !ok { + return ErrUnknownAssessment + } + subject := ad.Assessment().Subject + if an.Answerer != subject { + return fmt.Errorf("%w: only the rated party may answer (answerer is not the assessment's subject)", ErrInvalid) + } + pub, ok := b.directory.PublicKey(an.Answerer) + if !ok { + return fmt.Errorf("%w: unknown answerer key", ErrInvalid) + } + if len(pub) != ed25519.PublicKeySize { + return fmt.Errorf("%w: directory returned a non-canonical answerer key length", ErrInvalid) + } + if MemberIDFor(b.platform, pub) != an.Answerer { + return fmt.Errorf("%w: directory key does not mint the answerer's member ID", ErrInvalid) + } + if !an.Verify(pub) { + return fmt.Errorf("%w: signature does not verify", ErrInvalid) + } + sig := make([]byte, len(an.Signature)) + copy(sig, an.Signature) + an.Signature = sig + return b.store.AppendAnswer(AdmittedAnswer{an: an}) +} + +// AnswerFor returns the admitted answer to the assessment id, if any. +func (b *Book) AnswerFor(id [32]byte) (Answer, bool, error) { + aa, ok, err := b.store.AnswerFor(id) + if err != nil || !ok { + return Answer{}, ok, err + } + return aa.Answer(), true, nil +} + // validMemberID reports whether m has the one admissible shape: exactly 64 // lowercase-hex characters, the output shape of MemberIDFor. Anything else — // in particular any human-chosen name — is rejected at the gate. diff --git a/internal/covenant/book_test.go b/internal/covenant/book_test.go index 13ccdf7..1fc20c3 100644 --- a/internal/covenant/book_test.go +++ b/internal/covenant/book_test.go @@ -31,6 +31,18 @@ func (c *countingStore) BySubject(m MemberID) ([]Admitted, error) { return c.inner.BySubject(m) } +func (c *countingStore) ByID(id [32]byte) (Admitted, bool, error) { + return c.inner.ByID(id) +} + +func (c *countingStore) AppendAnswer(aa AdmittedAnswer) error { + return c.inner.AppendAnswer(aa) +} + +func (c *countingStore) AnswerFor(id [32]byte) (AdmittedAnswer, bool, error) { + return c.inner.AnswerFor(id) +} + // testBook wires a Book over the given members' keys with the given seals, // using the LBTAS default category vocabulary. func testBook(t *testing.T, dir dirMap, seals sealSet, store Store) *Book { @@ -118,7 +130,7 @@ func TestRecordRejectsInvalid(t *testing.T) { {"unknown category", signed(withCategory(testAssessment(alice, bob, ref(0xAA), LevelBasicPromise), "warmth"), alicePriv)}, {"empty category", signed(withCategory(testAssessment(alice, bob, ref(0xAA), LevelBasicPromise), ""), alicePriv)}, {"no-trust without comment hash", signed(withoutCommentHash(testAssessment(alice, bob, ref(0xAA), LevelNoTrust)), alicePriv)}, - {"zero IssuedAt", signed(Assessment{Assessor: alice, Subject: bob, Exchange: ref(0xAA), Category: testCategory, Level: LevelBasicPromise}, alicePriv)}, + {"zero IssuedAt", signed(Assessment{Assessor: alice, Subject: bob, Exchange: ref(0xAA), Relation: RelationTrade, Category: testCategory, Level: LevelBasicPromise}, alicePriv)}, {"unknown assessor key", signed(testAssessment(charlie, bob, ref(0xCC), LevelBasicPromise), charliePriv)}, {"unsigned", testAssessment(alice, bob, ref(0xAA), LevelBasicPromise)}, {"signed then tampered", func() Assessment { @@ -193,11 +205,11 @@ func TestRecordNoTrustCommentHash(t *testing.T) { if err != nil { t.Fatalf("Standing = %v", err) } - if s.Harm() != 1 { - t.Errorf("Harm() = %d, want 1 — the admitted -1 must be surfaced", s.Harm()) + if s.Relation(RelationTrade).Harm() != 1 { + t.Errorf("Harm() = %d, want 1 — the admitted -1 must be surfaced", s.Relation(RelationTrade).Harm()) } - if s.Total() != 2 { - t.Errorf("Total() = %d, want 2", s.Total()) + if s.Relation(RelationTrade).Total() != 2 { + t.Errorf("Total() = %d, want 2", s.Relation(RelationTrade).Total()) } } @@ -456,18 +468,18 @@ func TestRecordDuplicate(t *testing.T) { if err != nil { t.Fatalf("Standing = %v", err) } - if s.Total() != 3 { - t.Errorf("Standing(bob).Total() = %d, want 3", s.Total()) + if s.Relation(RelationTrade).Total() != 3 { + t.Errorf("Standing(bob).Total() = %d, want 3", s.Relation(RelationTrade).Total()) } - if got := s.Category(testCategory).Total(); got != 2 { + if got := s.Relation(RelationTrade).Category(testCategory).Total(); got != 2 { t.Errorf("Category(%q).Total() = %d, want 2", testCategory, got) } - if got := s.Category("support").Total(); got != 1 { + if got := s.Relation(RelationTrade).Category("support").Total(); got != 1 { t.Errorf("Category(\"support\").Total() = %d, want 1", got) } - if s.Overall().Count(LevelBasicPromise) != 1 || s.Overall().Count(LevelBasicSatisfaction) != 2 { + if s.Relation(RelationTrade).Overall().Count(LevelBasicPromise) != 1 || s.Relation(RelationTrade).Overall().Count(LevelBasicSatisfaction) != 2 { t.Errorf("Overall() counts = basic-promise %d, basic-satisfaction %d; want 1, 2", - s.Overall().Count(LevelBasicPromise), s.Overall().Count(LevelBasicSatisfaction)) + s.Relation(RelationTrade).Overall().Count(LevelBasicPromise), s.Relation(RelationTrade).Overall().Count(LevelBasicSatisfaction)) } } @@ -510,6 +522,7 @@ func standingFixture(t *testing.T) (*Book, Store, MemberID, MemberID) { Assessor: assessor, Subject: v.subject, Exchange: ex, + Relation: RelationTrade, Category: v.category, Level: v.level, IssuedAt: time.Unix(1700000000+int64(i), 0).UTC(), @@ -553,27 +566,27 @@ func TestStandingShape(t *testing.T) { if err != nil { t.Fatalf("Standing(%s) = %v", sub.name, err) } - if s.Total() != 4 { - t.Errorf("Standing(%s).Total() = %d, want 4", sub.name, s.Total()) + if s.Relation(RelationTrade).Total() != 4 { + t.Errorf("Standing(%s).Total() = %d, want 4", sub.name, s.Relation(RelationTrade).Total()) } for _, l := range Levels() { - if got := s.Overall().Count(l); got != sub.want[l] { + if got := s.Relation(RelationTrade).Overall().Count(l); got != sub.want[l] { t.Errorf("Standing(%s).Overall().Count(%s) = %d, want %d", sub.name, l, got, sub.want[l]) } } for cat, lvl := range sub.byCat { - d := s.Category(cat) + d := s.Relation(RelationTrade).Category(cat) if d.Total() != 1 || d.Count(lvl) != 1 { t.Errorf("Standing(%s).Category(%q) = total %d, %s %d; want 1, 1", sub.name, cat, d.Total(), lvl, d.Count(lvl)) } } - if got := s.Harm(); got != sub.harm { + if got := s.Relation(RelationTrade).Harm(); got != sub.harm { t.Errorf("Standing(%s).Harm() = %d, want %d — Harm counts every No Trust verdict across all categories", sub.name, got, sub.harm) } - if s.Overall().Count(Level(99)) != 0 { + if s.Relation(RelationTrade).Overall().Count(Level(99)) != 0 { t.Errorf("Count of an unknown level must be 0") } - if d := s.Category("no-such-category"); d.Total() != 0 { + if d := s.Relation(RelationTrade).Category("no-such-category"); d.Total() != 0 { t.Errorf("Category of an unknown name must be empty, got total %d", d.Total()) } } @@ -585,7 +598,7 @@ func TestStandingShape(t *testing.T) { dv, _ := b.Standing(volatile) same := true for _, l := range Levels() { - if ds.Overall().Count(l) != dv.Overall().Count(l) { + if ds.Relation(RelationTrade).Overall().Count(l) != dv.Relation(RelationTrade).Overall().Count(l) { same = false } } @@ -609,23 +622,23 @@ func TestStandingCategoryOverallConsistency(t *testing.T) { for _, l := range Levels() { pooled := 0 for _, cat := range categories { - pooled += s.Category(cat).Count(l) + pooled += s.Relation(RelationTrade).Category(cat).Count(l) } - if got := s.Overall().Count(l); got != pooled { + if got := s.Relation(RelationTrade).Overall().Count(l); got != pooled { t.Errorf("Overall().Count(%s) = %d, want %d — the per-category counts pooled", l, got, pooled) } } pooledTotal := 0 for _, cat := range categories { - pooledTotal += s.Category(cat).Total() + pooledTotal += s.Relation(RelationTrade).Category(cat).Total() } - if s.Overall().Total() != pooledTotal || s.Total() != pooledTotal { + if s.Relation(RelationTrade).Overall().Total() != pooledTotal || s.Relation(RelationTrade).Total() != pooledTotal { t.Errorf("Total() = %d, Overall().Total() = %d, want the pooled per-category total %d", - s.Total(), s.Overall().Total(), pooledTotal) + s.Relation(RelationTrade).Total(), s.Relation(RelationTrade).Overall().Total(), pooledTotal) } - if s.Harm() != s.Overall().Count(LevelNoTrust) { + if s.Relation(RelationTrade).Harm() != s.Relation(RelationTrade).Overall().Count(LevelNoTrust) { t.Errorf("Harm() = %d, want Overall().Count(LevelNoTrust) = %d — Harm is the -1 count, nothing else", - s.Harm(), s.Overall().Count(LevelNoTrust)) + s.Relation(RelationTrade).Harm(), s.Relation(RelationTrade).Overall().Count(LevelNoTrust)) } } } @@ -652,15 +665,15 @@ func TestStandingLossless(t *testing.T) { if err != nil { t.Fatalf("Standing = %v", err) } - if s.Total() != len(ads) { - t.Errorf("Total() = %d, want the store multiset size %d", s.Total(), len(ads)) + if s.Relation(RelationTrade).Total() != len(ads) { + t.Errorf("Total() = %d, want the store multiset size %d", s.Relation(RelationTrade).Total(), len(ads)) } for _, l := range Levels() { - if s.Overall().Count(l) != recount[l] { - t.Errorf("Overall().Count(%s) = %d, want %d from the store multiset — the Distribution must be the full distribution, not a summary", l, s.Overall().Count(l), recount[l]) + if s.Relation(RelationTrade).Overall().Count(l) != recount[l] { + t.Errorf("Overall().Count(%s) = %d, want %d from the store multiset — the Distribution must be the full distribution, not a summary", l, s.Relation(RelationTrade).Overall().Count(l), recount[l]) } for cat, want := range recountByCat { - if got := s.Category(cat).Count(l); got != want[l] { + if got := s.Relation(RelationTrade).Category(cat).Count(l); got != want[l] { t.Errorf("Category(%q).Count(%s) = %d, want %d from the store multiset", cat, l, got, want[l]) } } @@ -675,19 +688,19 @@ func TestStandingUnknownMember(t *testing.T) { if err != nil { t.Fatalf("Standing of a never-assessed member = %v, want nil error", err) } - if s.Total() != 0 { - t.Errorf("Total() = %d, want 0", s.Total()) + if s.Relation(RelationTrade).Total() != 0 { + t.Errorf("Total() = %d, want 0", s.Relation(RelationTrade).Total()) } - if s.Harm() != 0 { - t.Errorf("Harm() = %d, want 0", s.Harm()) + if s.Relation(RelationTrade).Harm() != 0 { + t.Errorf("Harm() = %d, want 0", s.Relation(RelationTrade).Harm()) } for _, l := range Levels() { - if s.Overall().Count(l) != 0 { - t.Errorf("Overall().Count(%s) = %d, want 0", l, s.Overall().Count(l)) + if s.Relation(RelationTrade).Overall().Count(l) != 0 { + t.Errorf("Overall().Count(%s) = %d, want 0", l, s.Relation(RelationTrade).Overall().Count(l)) } } for _, cat := range []string{"reliability", "usability", "performance", "support"} { - if d := s.Category(cat); d.Total() != 0 { + if d := s.Relation(RelationTrade).Category(cat); d.Total() != 0 { t.Errorf("Category(%q).Total() = %d, want 0", cat, d.Total()) } } @@ -701,6 +714,14 @@ type hostileStore struct { replay []Admitted } +// *hostileStore implements the answer/ID half of Store trivially: these fakes exist to +// probe assessment-path behavior only. +func (h *hostileStore) ByID([32]byte) (Admitted, bool, error) { return Admitted{}, false, nil } +func (h *hostileStore) AppendAnswer(AdmittedAnswer) error { return nil } +func (h *hostileStore) AnswerFor([32]byte) (AdmittedAnswer, bool, error) { + return AdmittedAnswer{}, false, nil +} + func (h *hostileStore) Append(ad Admitted) error { h.replay = append(h.replay, ad) return nil @@ -752,10 +773,10 @@ func TestStandingDoesNotTrustTheStore(t *testing.T) { if err != nil { t.Fatalf("Standing(bob) = %v", err) } - if s.Total() != 1 || s.Overall().Count(LevelDelight) != 1 { - t.Errorf("Standing(bob) over a replaying store = total %d, delight %d; want 1, 1 — a store replay must not inflate standing", s.Total(), s.Overall().Count(LevelDelight)) + if s.Relation(RelationTrade).Total() != 1 || s.Relation(RelationTrade).Overall().Count(LevelDelight) != 1 { + t.Errorf("Standing(bob) over a replaying store = total %d, delight %d; want 1, 1 — a store replay must not inflate standing", s.Relation(RelationTrade).Total(), s.Relation(RelationTrade).Overall().Count(LevelDelight)) } - if got := s.Category(testCategory).Count(LevelDelight); got != 1 { + if got := s.Relation(RelationTrade).Category(testCategory).Count(LevelDelight); got != 1 { t.Errorf("Category(%q).Count(Delight) = %d, want 1", testCategory, got) } } @@ -768,6 +789,14 @@ type replayStore struct { ads []Admitted } +// *replayStore implements the answer/ID half of Store trivially: these fakes exist to +// probe assessment-path behavior only. +func (r *replayStore) ByID([32]byte) (Admitted, bool, error) { return Admitted{}, false, nil } +func (r *replayStore) AppendAnswer(AdmittedAnswer) error { return nil } +func (r *replayStore) AnswerFor([32]byte) (AdmittedAnswer, bool, error) { + return AdmittedAnswer{}, false, nil +} + func (r *replayStore) Append(ad Admitted) error { r.ads = append(r.ads, ad) return nil @@ -805,13 +834,13 @@ func TestStandingDedupIsPerCategory(t *testing.T) { if err != nil { t.Fatalf("Standing = %v", err) } - if s.Total() != 2 { - t.Errorf("Total() = %d, want 2 — one per (assessor, exchange, category) triple", s.Total()) + if s.Relation(RelationTrade).Total() != 2 { + t.Errorf("Total() = %d, want 2 — one per (assessor, exchange, category) triple", s.Relation(RelationTrade).Total()) } - if got := s.Category("reliability").Total(); got != 1 { + if got := s.Relation(RelationTrade).Category("reliability").Total(); got != 1 { t.Errorf("Category(reliability).Total() = %d, want 1 — the same-triple replay must be counted once", got) } - if got := s.Category("support").Total(); got != 1 { + if got := s.Relation(RelationTrade).Category("support").Total(); got != 1 { t.Errorf("Category(support).Total() = %d, want 1 — a second category is a distinct verdict slot", got) } } @@ -822,12 +851,12 @@ func TestStandingNotSerializable(t *testing.T) { if err != nil { t.Fatalf("Standing = %v", err) } - if s.Total() == 0 { + if s.Relation(RelationTrade).Total() == 0 { t.Fatal("fixture must yield a populated Standing") } for name, v := range map[string]interface{}{ "Standing": s, - "Distribution": s.Overall(), + "Distribution": s.Relation(RelationTrade).Overall(), } { out, err := json.Marshal(v) if err != nil { @@ -910,6 +939,14 @@ func TestNoCollapseFunctionTripwire(t *testing.T) { // Store is trusted with anything. type hostileZeroStore struct{ queried int } +// *hostileZeroStore implements the answer/ID half of Store trivially: these fakes exist to +// probe assessment-path behavior only. +func (h *hostileZeroStore) ByID([32]byte) (Admitted, bool, error) { return Admitted{}, false, nil } +func (h *hostileZeroStore) AppendAnswer(AdmittedAnswer) error { return nil } +func (h *hostileZeroStore) AnswerFor([32]byte) (AdmittedAnswer, bool, error) { + return AdmittedAnswer{}, false, nil +} + func (h *hostileZeroStore) Append(Admitted) error { return nil } func (h *hostileZeroStore) BySubject(MemberID) ([]Admitted, error) { h.queried++ @@ -930,8 +967,8 @@ func TestStandingRejectsUnmintedSubject(t *testing.T) { if !errors.Is(err, ErrInvalid) { t.Errorf("Standing(%q) error = %v, want ErrInvalid", subject, err) } - if s.Total() != 0 { - t.Errorf("Standing(%q) counted %d phantom entries, want 0", subject, s.Total()) + if s.Relation(RelationTrade).Total() != 0 { + t.Errorf("Standing(%q) counted %d phantom entries, want 0", subject, s.Relation(RelationTrade).Total()) } } if hostile.queried != 0 { diff --git a/internal/covenant/memstore.go b/internal/covenant/memstore.go index 8bd6df4..6b65c67 100644 --- a/internal/covenant/memstore.go +++ b/internal/covenant/memstore.go @@ -11,8 +11,10 @@ import ( // package. type MemStore struct { mu sync.Mutex - seen map[string]struct{} // (assessor, exchange, category) uniqueness keys + seen map[string]struct{} // (assessor, exchange, relation, category) uniqueness keys bySubject map[MemberID][]Admitted // append order per subject + byID map[[32]byte]Admitted // Assessment.ID() -> admitted + answers map[[32]byte]AdmittedAnswer } // NewMemStore returns an empty in-memory Store. @@ -20,6 +22,8 @@ func NewMemStore() *MemStore { return &MemStore{ seen: make(map[string]struct{}), bySubject: make(map[MemberID][]Admitted), + byID: make(map[[32]byte]Admitted), + answers: make(map[[32]byte]AdmittedAnswer), } } @@ -32,7 +36,7 @@ func (s *MemStore) Append(ad Admitted) error { if a.Assessor == "" { return fmt.Errorf("%w: zero Admitted", ErrInvalid) } - key := string(a.Assessor) + "\x00" + string(a.Exchange[:]) + "\x00" + a.Category + key := string(a.Assessor) + "\x00" + string(a.Exchange[:]) + "\x00" + string(a.Relation) + "\x00" + a.Category s.mu.Lock() defer s.mu.Unlock() if _, dup := s.seen[key]; dup { @@ -40,9 +44,41 @@ func (s *MemStore) Append(ad Admitted) error { } s.seen[key] = struct{}{} s.bySubject[a.Subject] = append(s.bySubject[a.Subject], ad) + s.byID[a.ID()] = ad return nil } +// ByID implements Store. +func (s *MemStore) ByID(id [32]byte) (Admitted, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + ad, ok := s.byID[id] + return ad, ok, nil +} + +// AppendAnswer implements Store: one answer per assessment, forever. +func (s *MemStore) AppendAnswer(aa AdmittedAnswer) error { + an := aa.Answer() + if an.Answerer == "" { + return fmt.Errorf("%w: zero AdmittedAnswer", ErrInvalid) + } + s.mu.Lock() + defer s.mu.Unlock() + if _, dup := s.answers[an.Assessment]; dup { + return ErrDuplicate + } + s.answers[an.Assessment] = aa + return nil +} + +// AnswerFor implements Store. +func (s *MemStore) AnswerFor(id [32]byte) (AdmittedAnswer, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + aa, ok := s.answers[id] + return aa, ok, nil +} + // BySubject implements Store: admitted assessments about subject, in append // order, as defensive copies — the returned slice is fresh, and Admitted // exposes its assessment only through a copying accessor, so no caller can diff --git a/internal/covenant/memstore_test.go b/internal/covenant/memstore_test.go index c4627d6..9896b44 100644 --- a/internal/covenant/memstore_test.go +++ b/internal/covenant/memstore_test.go @@ -15,6 +15,7 @@ func admitted(assessor, subject MemberID, ex ExchangeRef, category string, l Lev Assessor: assessor, Subject: subject, Exchange: ex, + Relation: RelationTrade, Category: category, Level: l, IssuedAt: time.Unix(1700000000, 0).UTC(), diff --git a/internal/covenant/relation_test.go b/internal/covenant/relation_test.go new file mode 100644 index 0000000..289e9a5 --- /dev/null +++ b/internal/covenant/relation_test.go @@ -0,0 +1,226 @@ +package covenant + +import ( + "crypto/sha256" + "errors" + "testing" + "time" +) + +// adjSet is a test Anchors with BOTH anchor kinds configurable. +type adjSet struct { + seals sealSet + adj map[string]struct{} +} + +func newAdjSet() *adjSet { + return &adjSet{seals: sealSet{}, adj: map[string]struct{}{}} +} + +func (a *adjSet) adjudicate(ex ExchangeRef, assessor, subject MemberID) { + a.adj[string(ex[:])+"\x00"+string(assessor)+"\x00"+string(subject)] = struct{}{} +} + +func (a *adjSet) Sealed(ex ExchangeRef, assessor, subject MemberID) bool { + return a.seals.Sealed(ex, assessor, subject) +} + +func (a *adjSet) Adjudicated(ex ExchangeRef, assessor, subject MemberID) bool { + _, ok := a.adj[string(ex[:])+"\x00"+string(assessor)+"\x00"+string(subject)] + return ok +} + +// TestRelationsAreTypedAndGated: the relation vocabulary is closed; trade +// anchors on the sealed exchange; the adjudication relations anchor on real +// claim participation; and the same exchange supports one verdict per +// relation per category without colliding. +func TestRelationsAreTypedAndGated(t *testing.T) { + member, pub, priv := testMember(1) + operator, opPub, _ := testMember(2) + dir := dirMap{member: pub, operator: opPub} + anchors := newAdjSet() + store := NewMemStore() + b, err := NewBook(testPlatform, nil, dir, anchors, store) + if err != nil { + t.Fatalf("NewBook: %v", err) + } + ex := ref(0x11) + anchors.seals.seal(ex, member, operator) + + base := func(rel Relation) Assessment { + a := Assessment{ + Assessor: member, + Subject: operator, + Exchange: ex, + Relation: rel, + Category: testCategory, + Level: LevelBasicPromise, + IssuedAt: time.Unix(1700000000, 0).UTC(), + } + return a + } + + // Closed vocabulary. + bad := base("vibes") + bad.Sign(priv) + if err := b.Record(bad); !errors.Is(err, ErrInvalid) { + t.Fatalf("unknown relation admitted: %v", err) + } + empty := base("") + empty.Sign(priv) + if err := b.Record(empty); !errors.Is(err, ErrInvalid) { + t.Fatalf("empty relation admitted: %v", err) + } + + // Trade anchors on the seal (present) — admitted. + trade := base(RelationTrade) + trade.Sign(priv) + if err := b.Record(trade); err != nil { + t.Fatalf("trade: %v", err) + } + + // Conduct WITHOUT an adjudicated claim is refused… + conduct := base(RelationAdjudicationConduct) + conduct.Sign(priv) + if err := b.Record(conduct); !errors.Is(err, ErrInvalid) { + t.Fatalf("conduct without claim admitted: %v", err) + } + // …and admitted once the member was party to a real claim. + anchors.adjudicate(ex, member, operator) + conduct = base(RelationAdjudicationConduct) + conduct.Sign(priv) + if err := b.Record(conduct); err != nil { + t.Fatalf("conduct with claim: %v", err) + } + // Verdict-satisfaction rides the same anchor. + vs := base(RelationVerdictSatisfaction) + vs.Level = LevelNoTrust // a losing party's displeasure… + vs.CommentHash = commentHash(0xAB) + vs.Sign(priv) + if err := b.Record(vs); err != nil { + t.Fatalf("verdict-satisfaction: %v", err) + } + + // One verdict per (assessor, exchange, RELATION, category): the trade + // verdict did not block the conduct verdict, but a trade replay is dup. + dup := base(RelationTrade) + dup.Level = LevelDelight + dup.Sign(priv) + if err := b.Record(dup); !errors.Is(err, ErrDuplicate) { + t.Fatalf("same-relation replay: %v", err) + } + + // Standing keeps the streams apart — the verdict-satisfaction No Trust + // does NOT appear in adjudication-conduct or trade, and there is no + // cross-relation pool anywhere on the type. + s, err := b.Standing(operator) + if err != nil { + t.Fatalf("Standing: %v", err) + } + if s.Relation(RelationTrade).Harm() != 0 || s.Relation(RelationAdjudicationConduct).Harm() != 0 { + t.Fatal("a verdict-satisfaction No Trust leaked into another relation's harm count") + } + if s.Relation(RelationVerdictSatisfaction).Harm() != 1 { + t.Fatal("the verdict-satisfaction No Trust must be visible in its own stream") + } + if s.Relation(RelationTrade).Total() != 1 || s.Relation(RelationAdjudicationConduct).Total() != 1 || s.Relation(RelationVerdictSatisfaction).Total() != 1 { + t.Fatalf("per-relation totals = %d/%d/%d, want 1/1/1", + s.Relation(RelationTrade).Total(), s.Relation(RelationAdjudicationConduct).Total(), s.Relation(RelationVerdictSatisfaction).Total()) + } +} + +// TestAnswersCloseTheSymmetryBreach: every claim is answerable — the rated +// party (adjudicator included) answers with a signed annotation; the +// assessment is never altered; only the subject may answer; one answer per +// assessment; an answer to nothing is refused. +func TestAnswersCloseTheSymmetryBreach(t *testing.T) { + member, pub, priv := testMember(1) + operator, opPub, opPriv := testMember(2) + dir := dirMap{member: pub, operator: opPub} + anchors := newAdjSet() + store := NewMemStore() + b, err := NewBook(testPlatform, nil, dir, anchors, store) + if err != nil { + t.Fatalf("NewBook: %v", err) + } + ex := ref(0x22) + anchors.adjudicate(ex, member, operator) + + // The member rates the OPERATOR's adjudication conduct No Trust — the + // exact configuration the architecture named as the broken symmetry. + a := Assessment{ + Assessor: member, + Subject: operator, + Exchange: ex, + Relation: RelationAdjudicationConduct, + Category: testCategory, + Level: LevelNoTrust, + CommentHash: commentHash(0xCD), + IssuedAt: time.Unix(1700000000, 0).UTC(), + } + a.Sign(priv) + if err := b.Record(a); err != nil { + t.Fatalf("Record: %v", err) + } + + answerHash := sha256.Sum256([]byte("member-local response narrative")) + an := Answer{ + Assessment: a.ID(), + Answerer: operator, + AnswerHash: answerHash, + IssuedAt: time.Unix(1700000100, 0).UTC(), + } + + // Only the rated party answers: the assessor's own signature is refused. + wrong := an + wrong.Answerer = member + wrong.Sign(priv) + if err := b.RecordAnswer(wrong); !errors.Is(err, ErrInvalid) { + t.Fatalf("non-subject answer admitted: %v", err) + } + + // The adjudicator answers — the recourse exists. + an.Sign(opPriv) + if err := b.RecordAnswer(an); err != nil { + t.Fatalf("RecordAnswer: %v", err) + } + got, ok, err := b.AnswerFor(a.ID()) + if err != nil || !ok { + t.Fatalf("AnswerFor: ok=%v err=%v", ok, err) + } + if got.Answerer != operator || got.AnswerHash != answerHash { + t.Fatal("stored answer does not match") + } + + // The answer annotates; the assessment's standing is untouched. + s, err := b.Standing(operator) + if err != nil { + t.Fatalf("Standing: %v", err) + } + if s.Relation(RelationAdjudicationConduct).Harm() != 1 { + t.Fatal("an answer must never dilute or erase the harm it answers") + } + + // One answer per assessment, forever. + again := an + again.IssuedAt = an.IssuedAt.Add(time.Hour) + again.Sign(opPriv) + if err := b.RecordAnswer(again); !errors.Is(err, ErrDuplicate) { + t.Fatalf("second answer admitted: %v", err) + } + + // Answering a nonexistent assessment is refused with the named error. + ghost := Answer{Assessment: [32]byte{9}, Answerer: operator, AnswerHash: answerHash, IssuedAt: an.IssuedAt} + ghost.Sign(opPriv) + if err := b.RecordAnswer(ghost); !errors.Is(err, ErrUnknownAssessment) { + t.Fatalf("ghost answer: %v", err) + } + + // A tampered answer signature is refused. + tampered := Answer{Assessment: a.ID(), Answerer: operator, AnswerHash: commentHash(0xEE), IssuedAt: an.IssuedAt} + tampered.Sign(opPriv) + tampered.AnswerHash = commentHash(0xEF) + if err := b.RecordAnswer(tampered); !errors.Is(err, ErrInvalid) { + t.Fatalf("tampered answer admitted: %v", err) + } +} diff --git a/internal/covenant/standing.go b/internal/covenant/standing.go index 7710835..af44d9f 100644 --- a/internal/covenant/standing.go +++ b/internal/covenant/standing.go @@ -1,43 +1,60 @@ package covenant -// Standing is the LBTAS read view of one member's reputation: per-category -// distributions, the pooled overall distribution, and the harm count. It is -// built only by Book.Standing; its state is unexported, so it cannot be -// constructed with fabricated counts or serialized through this package's -// types. Deliberately absent, like everywhere else in the package: any mean, -// average, or scalar summary of level VALUES, and any comparison or ordering -// between members. +// Standing is the LBTAS read view of one member's reputation, typed by +// relation: for each relation, per-category distributions, the pooled +// distribution within that relation, and the harm count. It is built only by +// Book.Standing; its state is unexported, so it cannot be constructed with +// fabricated counts or serialized through this package's types. Deliberately +// absent, like everywhere else in the package: any mean, average, or scalar +// summary of level VALUES; any comparison or ordering between members; and — +// new with typed relations — ANY POOL ACROSS RELATIONS, which would be the +// forbidden average committed across relations instead of across ratings. type Standing struct { + perRelation map[Relation]RelationStanding +} + +// Relation returns the standing under one typed relation. A relation with no +// assessments yields an empty RelationStanding with Total zero, not an error. +func (s Standing) Relation(r Relation) RelationStanding { + return s.perRelation[r] +} + +// RelationStanding is one relation's slice of a member's standing: +// per-category distributions and the pooled distribution within this one +// relation only. +type RelationStanding struct { byCategory map[string]Distribution overall Distribution } // Category returns the distribution of admitted assessments about the -// subject under the named category. A category with no assessments — or a -// name outside the vocabulary — yields an empty Distribution with Total -// zero, not an error. -func (s Standing) Category(name string) Distribution { - return s.byCategory[name] +// subject under the named category, within this relation. A category with no +// assessments — or a name outside the vocabulary — yields an empty +// Distribution with Total zero, not an error. +func (rs RelationStanding) Category(name string) Distribution { + return rs.byCategory[name] } -// Overall returns the pooled distribution across all categories: the same -// verdicts as the per-category views, counted once each, in one histogram. -func (s Standing) Overall() Distribution { - return s.overall +// Overall returns the pooled distribution across all categories WITHIN THIS +// RELATION: the same verdicts as the per-category views, counted once each, +// in one histogram. It never pools across relations. +func (rs RelationStanding) Overall() Distribution { + return rs.overall } -// Total returns the number of admitted assessments across all categories — -// the distribution's size. Per LBTAS this is itself a signal (transaction -// volume, and a proxy for time in service), never a denominator for a mean. -func (s Standing) Total() int { - return s.overall.Total() +// Total returns the number of admitted assessments in this relation across +// all categories — the distribution's size. Per LBTAS this is itself a +// signal (transaction volume, and a proxy for time in service), never a +// denominator for a mean. +func (rs RelationStanding) Total() int { + return rs.overall.Total() } -// Harm returns the count of No Trust (-1) verdicts across all categories. -// This is a per-level count surfaced by name — the never-diluted signal -// LBTAS mandates — NOT a collapse of the distribution: it is exactly -// Overall().Count(LevelNoTrust), raised so a single -1 can never hide -// behind surrounding praise. -func (s Standing) Harm() int { - return s.overall.Count(LevelNoTrust) +// Harm returns the count of No Trust (-1) verdicts in this relation. This is +// a per-level count surfaced by name — the never-diluted signal LBTAS +// mandates — NOT a collapse of the distribution. Readers MUST keep relation +// context when acting on it: a No Trust under verdict-satisfaction is a +// losing party's displeasure, not operator misconduct. +func (rs RelationStanding) Harm() int { + return rs.overall.Count(LevelNoTrust) } diff --git a/test/composition/composition_test.go b/test/composition/composition_test.go index f44bc48..d6ee55b 100644 --- a/test/composition/composition_test.go +++ b/test/composition/composition_test.go @@ -156,6 +156,13 @@ func (a *recordAnchors) Sealed(exchange covenant.ExchangeRef, assessor, subject (bytes.Equal(e.Proposer, subjectKey) && bytes.Equal(e.Acceptor, assessorKey)) } +// Adjudicated implements the adjudication-relation anchor; the composition +// test exercises trade assessments only, so it answers false and the typed +// relations are covered by the covenant package's own tests. +func (a *recordAnchors) Adjudicated(covenant.ExchangeRef, covenant.MemberID, covenant.MemberID) bool { + return false +} + // disputeAnchors implements dispute.Anchors. It is the dispute-side twin of // recordAnchors: same join to the operator's record log on Entry.ID(), but the // dispute port speaks raw ed25519 public keys (not covenant MemberIDs), so the @@ -445,6 +452,7 @@ func TestMemberStoryEndToEnd(t *testing.T) { Assessor: aliceMember, Subject: bobMember, Exchange: ref, + Relation: covenant.RelationTrade, Category: "reliability", Level: covenant.LevelDelight, IssuedAt: time.Now().UTC(), @@ -457,6 +465,7 @@ func TestMemberStoryEndToEnd(t *testing.T) { Assessor: bobMember, Subject: aliceMember, Exchange: ref, + Relation: covenant.RelationTrade, Category: "reliability", Level: covenant.LevelBasicSatisfaction, IssuedAt: time.Now().UTC(), @@ -484,6 +493,7 @@ func TestMemberStoryEndToEnd(t *testing.T) { Assessor: aliceMember, Subject: bobMember, Exchange: ref, + Relation: covenant.RelationTrade, Category: "support", Level: covenant.LevelNoTrust, CommentHash: [32]byte(commentHash), @@ -522,25 +532,25 @@ func TestMemberStoryEndToEnd(t *testing.T) { if err != nil { t.Fatalf("bob standing: %v", err) } - assertDist(bobStanding.Category("reliability"), map[covenant.Level]int{covenant.LevelDelight: 1}) - assertDist(bobStanding.Category("support"), map[covenant.Level]int{covenant.LevelNoTrust: 1}) - assertDist(bobStanding.Overall(), map[covenant.Level]int{ + assertDist(bobStanding.Relation(covenant.RelationTrade).Category("reliability"), map[covenant.Level]int{covenant.LevelDelight: 1}) + assertDist(bobStanding.Relation(covenant.RelationTrade).Category("support"), map[covenant.Level]int{covenant.LevelNoTrust: 1}) + assertDist(bobStanding.Relation(covenant.RelationTrade).Overall(), map[covenant.Level]int{ covenant.LevelDelight: 1, covenant.LevelNoTrust: 1, }) - if got := bobStanding.Total(); got != 2 { + if got := bobStanding.Relation(covenant.RelationTrade).Total(); got != 2 { t.Fatalf("bob standing total: got %d, want 2", got) } - if got := bobStanding.Harm(); got != 1 { + if got := bobStanding.Relation(covenant.RelationTrade).Harm(); got != 1 { t.Fatalf("bob Harm(): got %d, want 1 — the -1 must stay surfaced after its comment text is erased", got) } aliceStanding, err := st.book.Standing(aliceMember) if err != nil { t.Fatalf("alice standing: %v", err) } - assertDist(aliceStanding.Category("reliability"), map[covenant.Level]int{covenant.LevelBasicSatisfaction: 1}) - assertDist(aliceStanding.Overall(), map[covenant.Level]int{covenant.LevelBasicSatisfaction: 1}) - if got := aliceStanding.Harm(); got != 0 { + assertDist(aliceStanding.Relation(covenant.RelationTrade).Category("reliability"), map[covenant.Level]int{covenant.LevelBasicSatisfaction: 1}) + assertDist(aliceStanding.Relation(covenant.RelationTrade).Overall(), map[covenant.Level]int{covenant.LevelBasicSatisfaction: 1}) + if got := aliceStanding.Relation(covenant.RelationTrade).Harm(); got != 0 { t.Fatalf("alice Harm(): got %d, want 0", got) } } @@ -608,6 +618,7 @@ func TestModeIndependence(t *testing.T) { Assessor: aliceMember, Subject: bobMember, Exchange: covenant.ExchangeRef(e.ID()), + Relation: covenant.RelationTrade, Category: "performance", Level: covenant.LevelNoNegativeConsequences, IssuedAt: at, @@ -713,6 +724,7 @@ func TestNegativeJoins(t *testing.T) { Assessor: aliceMember, Subject: bobMember, Exchange: fabricated, + Relation: covenant.RelationTrade, Category: "reliability", Level: covenant.LevelBasicPromise, IssuedAt: time.Now().UTC(), @@ -731,6 +743,7 @@ func TestNegativeJoins(t *testing.T) { Assessor: carolMember, Subject: bobMember, Exchange: covenant.ExchangeRef(entry.ID()), + Relation: covenant.RelationTrade, Category: "reliability", Level: covenant.LevelBasicPromise, IssuedAt: time.Now().UTC(),