From e2c518cb35ccb52a23ecbe7ee8be2cd122b878a5 Mon Sep 17 00:00:00 2001 From: Noah Pederson Date: Fri, 9 Oct 2026 14:31:08 -0500 Subject: [PATCH] Add reference comments to vouch records Accept optional public comments of at most 300 characters. Preserve comments through PDS publication, ingestion, and SQLite storage. Migrate existing records with an empty comment. Attach incoming vouch comments to selected path nodes after trust calculation. Comments do not affect scores, paths, or record keys. Document the API field and treat comments as untrusted text. Go race tests, vet, schema lint, hooks, and Nix checks pass. tsk-20 --- README.md | 10 ++- docs/security-review.md | 3 + internal/appview/comments_test.go | 75 +++++++++++++++++++++ internal/appview/openapi.go | 5 +- internal/appview/service.go | 26 ++++++- internal/atproto/oauth.go | 5 +- internal/atproto/oauth_test.go | 42 ++++++++++++ internal/atproto/records.go | 6 +- internal/atproto/records_test.go | 4 +- internal/domain/comments_test.go | 19 ++++++ internal/domain/lexicon_test.go | 22 ++++-- internal/domain/vouch.go | 9 +++ internal/microcosm/comments_test.go | 46 +++++++++++++ internal/microcosm/events.go | 2 +- internal/storage/comments_test.go | 60 +++++++++++++++++ internal/storage/db/models.go | 1 + internal/storage/db/queries.sql.go | 10 ++- internal/storage/events.go | 5 +- internal/storage/migration3.sql | 1 + internal/storage/queries.sql | 2 +- internal/storage/schema.sql | 1 + internal/storage/sqlite.go | 18 ++++- internal/trust/graph.go | 1 + internal/ui/http.go | 3 +- internal/ui/integration_test.go | 13 +++- internal/ui/service.go | 3 + lexicons/computer/ngp/provenance/vouch.json | 1 + 27 files changed, 370 insertions(+), 23 deletions(-) create mode 100644 internal/appview/comments_test.go create mode 100644 internal/domain/comments_test.go create mode 100644 internal/microcosm/comments_test.go create mode 100644 internal/storage/comments_test.go create mode 100644 internal/storage/migration3.sql diff --git a/README.md b/README.md index fd68a10..be7b0c1 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ When a response includes `path`, it also includes `path_scores` in the same orde ```json [ {"did": "did:plc:source", "assertion": null, "score": 3}, - {"did": "did:plc:intermediate", "assertion": 2, "score": 2}, + {"did": "did:plc:intermediate", "assertion": 2, "score": 2, "comment": "Reference text from the source."}, {"did": "did:plc:target", "assertion": 1, "score": 0.9} ] ``` @@ -271,6 +271,14 @@ It is not an independent best-path calculation for that node. The source has no incoming assertion and uses the self score of 3. Direct blocks retain score -3. Unknown results contain neither path field. +Vouch records accept an optional `comment` string for public reference text. +Comments contain at most 300 Unicode code points. +The authenticated `PUT /api/vouch` endpoint accepts this field with `subject`, `tag`, and `score`. +Each `path_scores` node includes the comment from its incoming vouch, when that comment is not empty. +The source node has no incoming comment. Existing records without comments remain valid. +Comments do not affect scores, path selection, tags, or record keys. +Clients must treat comments as untrusted text, not HTML. + The score equals the maximum path bottleneck minus its additional-hop penalty, clamped to [-2, 2]. A direct block by the querying user returns -3 and overrides all paths. Other blocks participate in graph calculations as -3. diff --git a/docs/security-review.md b/docs/security-review.md index b9a87df..f796546 100644 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -30,6 +30,7 @@ - Invalid record updates cannot preserve an obsolete positive assertion. - Microcosm endpoints come only from operator configuration, not browser input. - Missing payload lookup requires the stream's exact URI and CID. +- Vouch comments are public, untrusted text. Clients must not interpret them as HTML. ## Correctness @@ -41,6 +42,8 @@ Direct blocks override paths. Other blocks participate in calculations. Optimistic selects the highest score across paths. ShortestPath selects the fewest hops, then the highest score among equal-length paths. ShortestPath removes ignored intermediaries and their edges before traversal. A direct block remains effective. Unknown targets never become trusted through a negative threshold. +The service attaches incoming vouch comments after calculation. Comments do not affect scores, path selection, or record keys. +The schema migration gives existing records an empty comment and preserves their assertions. ## Architecture diff --git a/internal/appview/comments_test.go b/internal/appview/comments_test.go new file mode 100644 index 0000000..b2a1a5e --- /dev/null +++ b/internal/appview/comments_test.go @@ -0,0 +1,75 @@ +package appview + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "provenance/internal/domain" + "provenance/internal/trust" + "provenance/internal/trust/algorithms" +) + +func TestPathComments(t *testing.T) { + for _, algorithm := range []trust.Algorithm{algorithms.Optimistic{}, algorithms.ShortestPath{}} { + t.Run(algorithm.Describe().Name, func(t *testing.T) { + service, _ := algorithmService(t, algorithm) + ctx := context.Background() + for _, blocked := range []bool{false, true} { + edges := []domain.Vouch{ + {Author: alice, Subject: carol, Score: -1, Comment: "Weak path."}, + {Author: alice, Subject: dan, Score: 2, Comment: "Strong path."}, + {Author: alice, Subject: dan, Tag: "work", Score: 2, Comment: "Topic reference."}, + } + if blocked { + edges = append(edges, domain.Vouch{Author: alice, Subject: bob, Score: domain.Blocked, Comment: "Block reference."}) + } + if err := service.store.Replace(ctx, alice, edges, time.Now()); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + Handler(service, serviceKey).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/reputation?source="+alice+"&target="+bob+"&tag=work&mode=fallback&include_path=true", nil)) + var got trust.Result + if w.Code != http.StatusOK { + t.Fatalf("response: %d %s", w.Code, w.Body) + } + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + want := "Topic reference." + if algorithm.Describe().Name == (algorithms.ShortestPath{}).Describe().Name { + want = "Weak path." + } + if blocked { + want = "Block reference." + } + if len(got.PathScores) < 2 || got.PathScores[0].Comment != "" || got.PathScores[1].Comment != want { + t.Fatalf("comments: %+v", got) + } + for _, node := range got.PathScores[2:] { + if node.Comment != "" { + t.Fatalf("unexpected comment: %+v", node) + } + } + } + }) + } +} + +func TestCommentResultCopy(t *testing.T) { + score := 2.0 + assertion := 2 + original := trust.Result{Status: trust.CalculatedStatus, Score: &score, Path: []string{alice, bob}, PathScores: []trust.PathScore{ + {DID: alice, Score: domain.SelfScore}, {DID: bob, Assertion: &assertion, Score: score}, + }} + got := pathComments(original, []domain.Vouch{{Author: alice, Subject: bob, Score: assertion, Comment: "Reference only."}}) + if original.PathScores[1].Comment != "" { + t.Fatal("changed an algorithm result buffer") + } + if got.Score != original.Score || got.PathScores[1].Score != score || got.PathScores[1].Assertion != &assertion || got.PathScores[1].Comment != "Reference only." { + t.Fatalf("changed calculation: %+v", got) + } +} diff --git a/internal/appview/openapi.go b/internal/appview/openapi.go index 93f27de..e12f0b1 100644 --- a/internal/appview/openapi.go +++ b/internal/appview/openapi.go @@ -131,12 +131,13 @@ func apiSchemas() map[string]any { evaluation[name] = schema } evaluation["trusted"] = scalarSchema("boolean") + comment := map[string]any{"type": "string", "maxLength": domain.MaxCommentLength, "description": "Reference text from the incoming vouch. This text does not affect trust calculations."} return map[string]any{ "Result": objectSchema(properties, "status", "score"), "Evaluation": objectSchema(evaluation, "status", "score", "trusted"), - "PathScore": objectSchema(map[string]any{"did": stringSchema, "assertion": map[string]any{"type": "integer", "nullable": true, "minimum": domain.Blocked, "maximum": domain.MaxScore}, "score": score}, "did", "assertion", "score"), + "PathScore": objectSchema(map[string]any{"did": stringSchema, "assertion": map[string]any{"type": "integer", "nullable": true, "minimum": domain.Blocked, "maximum": domain.MaxScore}, "score": score, "comment": comment}, "did", "assertion", "score"), "Error": objectSchema(map[string]any{"error": stringSchema}, "error"), - "Vouch": objectSchema(map[string]any{"author": stringSchema, "subject": stringSchema, "score": map[string]any{"type": "integer", "minimum": domain.Blocked, "maximum": domain.MaxScore}, "tag": stringSchema, "createdAt": stringSchema}, "author", "subject", "score", "tag"), + "Vouch": objectSchema(map[string]any{"author": stringSchema, "subject": stringSchema, "score": map[string]any{"type": "integer", "minimum": domain.Blocked, "maximum": domain.MaxScore}, "tag": stringSchema, "createdAt": stringSchema, "comment": comment}, "author", "subject", "score", "tag"), "Participant": objectSchema(map[string]any{"did": stringSchema, "threshold": scalarSchema("number"), "active": scalarSchema("boolean"), "syncedAt": stringSchema, "bootstrapUs": scalarSchema("integer")}, "did", "threshold", "active", "syncedAt", "bootstrapUs"), } } diff --git a/internal/appview/service.go b/internal/appview/service.go index cda7568..59120c3 100644 --- a/internal/appview/service.go +++ b/internal/appview/service.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "log/slog" + "slices" "strings" "sync" "time" @@ -111,7 +112,30 @@ func (s *Service) Reputation(ctx context.Context, source, target, tag string, mo return trust.Result{}, trust.ErrLimit } input.Edges = trust.Select(edges, tag, mode) - return s.algorithm.Calculate(ctx, input) + result, err := s.algorithm.Calculate(ctx, input) + if err != nil { + return trust.Result{}, err + } + return pathComments(result, input.Edges), nil +} + +// Attach reference text after calculation so that algorithms never select paths by comment content. +func pathComments(result trust.Result, edges []domain.Vouch) trust.Result { + if len(result.PathScores) == 0 { + return result + } + comments := make(map[[2]string]string, len(edges)) + for _, edge := range edges { + comments[[2]string{edge.Author, edge.Subject}] = edge.Comment + } + // Copy the returned nodes because an algorithm can reuse its result buffers. + result.PathScores = slices.Clone(result.PathScores) + result.PathScores[0].Comment = "" + for i := 1; i < len(result.PathScores); i++ { + pair := [2]string{result.PathScores[i-1].DID, result.PathScores[i].DID} + result.PathScores[i].Comment = comments[pair] + } + return result } func (s *Service) resolveInput(ctx context.Context, source, target string, options []trust.Accounts) (trust.Input, error) { diff --git a/internal/atproto/oauth.go b/internal/atproto/oauth.go index a5713fc..e033298 100644 --- a/internal/atproto/oauth.go +++ b/internal/atproto/oauth.go @@ -126,6 +126,9 @@ func (o *OAuth) PutVouch(ctx context.Context, session Session, v domain.Vouch) e if err := domain.ValidateTag(v.Tag); err != nil { return err } + if err := domain.ValidateComment(v.Comment); err != nil { + return err + } o.mu.Lock() defer o.mu.Unlock() sess, err := o.app.ResumeSession(ctx, syntax.DID(session.DID), session.ID) @@ -134,7 +137,7 @@ func (o *OAuth) PutVouch(ctx context.Context, session Session, v domain.Vouch) e } body := map[string]any{ "repo": session.DID, "collection": domain.Collection, "rkey": domain.RecordKey(v.Subject, v.Tag), - "record": Record{Type: domain.Collection, Subject: v.Subject, Score: &v.Score, Tag: v.Tag, CreatedAt: time.Now().UTC().Format(time.RFC3339Nano)}, + "record": Record{Type: domain.Collection, Subject: v.Subject, Score: &v.Score, Tag: v.Tag, CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), Comment: v.Comment}, } return sess.APIClient().Post(ctx, "com.atproto.repo.putRecord", body, nil) } diff --git a/internal/atproto/oauth_test.go b/internal/atproto/oauth_test.go index 48f5d69..f53bfd0 100644 --- a/internal/atproto/oauth_test.go +++ b/internal/atproto/oauth_test.go @@ -15,6 +15,7 @@ import ( "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" + "provenance/internal/domain" "provenance/internal/secure" "provenance/internal/storage" ) @@ -146,3 +147,44 @@ func TestOAuthRejectsInvalidIdentity(t *testing.T) { }) } } + +func TestPublishComment(t *testing.T) { + client, state := oauthFixture(t, testDID, "atproto") + ctx := context.Background() + if _, err := client.Begin(ctx, "alice.example.com", "browser"); err != nil { + t.Fatal(err) + } + session, err := client.Finish(ctx, url.Values{"state": {*state}, "iss": {testPDS}, "code": {"code"}}, "browser") + if err != nil { + t.Fatal(err) + } + var published Record + client.app.Client.Transport = transport(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/xrpc/com.atproto.repo.putRecord" { + t.Fatalf("unexpected request: %s", r.URL) + } + var body struct { + Repo string + Collection string + Rkey string + Record Record + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Repo != testDID || body.Collection != domain.Collection || body.Rkey != domain.RecordKey(body.Record.Subject, body.Record.Tag) { + t.Fatalf("publication: %+v", body) + } + published = body.Record + return response(map[string]any{}), nil + }) + for _, comment := range []string{"A reference.\n日本語