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(),