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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions internal/record/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
46 changes: 24 additions & 22 deletions internal/record/doc.go
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
//
Expand Down
12 changes: 9 additions & 3 deletions internal/record/entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 39 additions & 34 deletions internal/record/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}

Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
Loading
Loading