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
7 changes: 7 additions & 0 deletions cmd/cloudy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
178 changes: 174 additions & 4 deletions internal/consumerapi/api_slice2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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,
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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")
}
}
6 changes: 4 additions & 2 deletions internal/consumerapi/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}
}
}

Expand Down
91 changes: 91 additions & 0 deletions internal/consumerapi/assessments.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"net/http"
"time"

"github.com/NTARI-RAND/Cloudy/internal/covenant"
)
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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),
})
}
1 change: 1 addition & 0 deletions internal/consumerapi/disputes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading