From 65fecc26d2ed4c4ca1d802d97d8299544576ca54 Mon Sep 17 00:00:00 2001 From: Noah Pederson Date: Wed, 7 Oct 2026 21:40:36 -0500 Subject: [PATCH] Add path scores and publishable vouch schemas Expose incoming assertions and cumulative scores for each node on the selected trust path. Preserve existing path visibility rules. Use computer.ngp.provenance.vouch under provenance.ngp.computer. Add pinned goat tools for schema creation, lint, and publication. Document DNS authority, new OAuth permissions, and the transition from the old collection. --- Makefile | 14 +++- README.md | 75 ++++++++++++++++++- docs/design.md | 4 +- flake.nix | 12 +++ internal/appview/http.go | 2 + internal/appview/http_test.go | 23 +++++- internal/domain/lexicon_test.go | 40 ++++++++++ internal/domain/vouch.go | 2 +- internal/trust/graph.go | 51 +++++++++---- internal/trust/graph_test.go | 42 +++++++++++ .../ngp/provenance/vouch.json} | 3 +- nix/goat.nix | 50 +++++++++++++ nix/package.nix | 1 + 13 files changed, 294 insertions(+), 25 deletions(-) create mode 100644 internal/domain/lexicon_test.go rename lexicons/{dev.provenance.vouch.json => computer/ngp/provenance/vouch.json} (81%) create mode 100644 nix/goat.nix diff --git a/Makefile b/Makefile index 14d0b25..e64eb54 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ -.PHONY: test generate build +.PHONY: test generate build lex-new lex-check lex-publish + +VOUCH_SCHEMA := lexicons/computer/ngp/provenance/vouch.json test: go test -race ./... @@ -9,3 +11,13 @@ generate: build: go build -o bin/appview ./cmd/appview go build -o bin/ui ./cmd/ui + +lex-new: + @test -n "$(NSID)" || { echo "Set NSID to the new record schema name." >&2; exit 1; } + goat lex new record "$(NSID)" + +lex-check: + goat lex lint "$(VOUCH_SCHEMA)" + +lex-publish: lex-check + goat lex publish "$(VOUCH_SCHEMA)" diff --git a/README.md b/README.md index 07e5ae3..cfb9c39 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,21 @@ Fallback applies to each author-target pair and preserves explicit zero scores a Tags are case-sensitive and contain at most 128 Unicode code points. Add `include_path=true` to request a path for an untrusted result. Public queries omit paths unless the caller requests them. +When a response includes `path`, it also includes `path_scores` in the same order: + +```json +[ + {"did": "did:plc:source", "assertion": null, "score": 3}, + {"did": "did:plc:intermediate", "assertion": 2, "score": 2}, + {"did": "did:plc:target", "assertion": 1, "score": 0.9} +] +``` + +`assertion` is the incoming vouch score from the previous node. +`score` is the cumulative score along this path, with the hop penalty and clamp. +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. 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. @@ -379,12 +394,64 @@ The appview never schedules periodic PDS recovery. Bootstrap watermarks assume that the appview clock and stream timestamps are close to UTC. Keep the host clock synchronized. +## Lexicon tools + +The development shell includes ATProto `goat` v0.2.5, not the unrelated ASCII-art tool with the same name. + +```sh +nix develop path:. +make lex-check +``` + +To create another record schema, choose a new NSID: + +```sh +make lex-new NSID=computer.ngp.provenance.example +``` + +This command creates a template. Edit its fields before publication. +It refuses to replace an existing schema file. +The vouch schema already exists at `lexicons/computer/ngp/provenance/vouch.json`. + +To publish the vouch schema: + +1. Choose the ATProto account that will own the schema. +2. Add this DNS TXT record, with that account's DID: + + ```text + _lexicon.provenance.ngp.computer. TXT "did=did:plc:YOUR_SCHEMA_ACCOUNT" + ``` + +3. Run `goat lex check-dns` after DNS propagation. +4. Supply `GOAT_USERNAME` and `GOAT_PASSWORD` through your secret manager. + Use an app password for the schema account. +5. Run `make lex-publish`. +6. Retrieve the published record: + + ```sh + goat get at://YOUR_SCHEMA_ACCOUNT_DID/com.atproto.lexicon.schema/computer.ngp.provenance.vouch + ``` + +The publish command retains the DNS authority check and publishes only the vouch schema. +It does not update an existing schema by default. +Before an update, run `goat lex breaking` and review the changes. +Then use `goat lex publish --update lexicons/computer/ngp/provenance/vouch.json`. +Do not use `--skip-dns-check` for public publication. +Keep passwords out of command arguments and repository files. +`goat account login` stores credentials in plaintext. Prefer environment credentials for publication. +Provenance does not publish schemas during startup or builds. + +See the [ATProto publication guide](https://atproto.com/guides/publishing-lexicons) for the DNS authority and record format. + ## Deployment limits This project is an initial implementation, not an audited production service. -The provisional lexicon is `dev.provenance.vouch`. -Before production, replace it with a namespace under a domain that you control. -That change also requires new collection permissions and a record migration plan. +The vouch collection is `computer.ngp.provenance.vouch`, under the `provenance.ngp.computer` namespace authority. +After an upgrade from the provisional collection, log out and log in to grant the new collection permissions. +Old records do not move automatically. Republish vouches in the new collection. +Existing appview databases can still contain old assertions until an explicit recovery replaces each participant's snapshot. +Invoke `POST /v1/sync` for each affected participant after the namespace change. +Keep the old records and database backups until you complete the transition. The UI uses a public OAuth client with encrypted persistent sessions. Refresh tokens have server-defined lifetimes. Browser sessions expire after 14 days. @@ -412,5 +479,5 @@ The services reject databases with newer schema versions. The [design](docs/design.md) describes the service boundaries and trust rules. The [security review](docs/security-review.md) lists trust assumptions and operational risks. -The [lexicon](lexicons/dev.provenance.vouch.json) describes public vouch records. +The [lexicon](lexicons/computer/ngp/provenance/vouch.json) describes public vouch records. The `tsk` queue records the implementation roadmap. diff --git a/docs/design.md b/docs/design.md index 591bdd2..39bb3a2 100644 --- a/docs/design.md +++ b/docs/design.md @@ -71,8 +71,8 @@ A tagged block or zero assertion does not trigger fallback. ## Protocol -The provisional collection is `dev.provenance.vouch`. -This namespace is not ready for production until the operator owns its domain. +The collection is `computer.ngp.provenance.vouch`. +The namespace authority is `provenance.ngp.computer`. A deterministic record key identifies each target and tag pair. Records contain `subject` (DID), `score`, `tag`, and `createdAt`. The appview derives authorship from the participant repository, never from a record field. diff --git a/flake.nix b/flake.nix index 9942b6e..90f90a6 100644 --- a/flake.nix +++ b/flake.nix @@ -50,6 +50,7 @@ { default = provenance; inherit provenance; + goat = pkgs.callPackage ./nix/goat.nix { }; appview = provenance.override { pname = "provenance-appview"; subPackages = [ "cmd/appview" ]; @@ -95,6 +96,15 @@ PROVENANCE_DEV_BIND_HOST = "0.0.0.0"; }; pre-commit = hooks system; + lexicons = + pkgs.runCommand "provenance-lexicon-check" + { + nativeBuildInputs = [ self.packages.${system}.goat ]; + } + '' + goat lex lint ${./lexicons/computer/ngp/provenance/vouch.json} + touch "$out" + ''; } // nixpkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { modules = import ./nix/tests/modules.nix { @@ -120,6 +130,8 @@ pkgs.go pkgs.sqlc pkgs.sqlite + self.packages.${system}.goat + pkgs.gnumake ] ++ (hooks system).enabledPackages; shellHook = (hooks system).shellHook; diff --git a/internal/appview/http.go b/internal/appview/http.go index cfaff8b..de1b1de 100644 --- a/internal/appview/http.go +++ b/internal/appview/http.go @@ -73,6 +73,7 @@ func (h *HTTP) reputation(w http.ResponseWriter, r *http.Request) { } if r.URL.Query().Get("include_path") != "true" { result.Path = nil + result.PathScores = nil } httpx.JSON(w, http.StatusOK, result) } @@ -90,6 +91,7 @@ func (h *HTTP) relative(w http.ResponseWriter, r *http.Request) { } if !result.Trusted && r.URL.Query().Get("include_path") != "true" { result.Path = nil + result.PathScores = nil } httpx.JSON(w, http.StatusOK, result) } diff --git a/internal/appview/http_test.go b/internal/appview/http_test.go index 7631b40..464e1a1 100644 --- a/internal/appview/http_test.go +++ b/internal/appview/http_test.go @@ -65,6 +65,14 @@ func TestAuthenticatedAPI(t *testing.T) { if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != `{"trusted":true}` { t.Fatalf("trusted response: %d %s", w.Code, w.Body) } + w = call(http.MethodGet, "/v1/me/reputation?target="+bob, "", serviceKey) + var trusted Evaluation + if err := json.Unmarshal(w.Body.Bytes(), &trusted); err != nil { + t.Fatal(err) + } + if len(trusted.PathScores) != 2 || trusted.PathScores[1].Assertion == nil || *trusted.PathScores[1].Assertion != 1 || trusted.PathScores[1].Score != 1 { + t.Fatalf("trusted path scores absent: %s", w.Body) + } w = call(http.MethodPut, "/v1/me/threshold", `{"threshold":1.1}`, serviceKey) if w.Code != http.StatusOK { t.Fatalf("threshold response: %s", w.Body) @@ -74,14 +82,14 @@ func TestAuthenticatedAPI(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &evaluation); err != nil { t.Fatal(err) } - if evaluation.Trusted || evaluation.Path != nil { + if evaluation.Trusted || evaluation.Path != nil || evaluation.PathScores != nil { t.Fatalf("untrusted result exposed a path: %+v", evaluation) } w = call(http.MethodGet, "/v1/me/reputation?target="+bob+"&include_path=true", "", serviceKey) if err := json.Unmarshal(w.Body.Bytes(), &evaluation); err != nil { t.Fatal(err) } - if len(evaluation.Path) != 2 { + if len(evaluation.Path) != 2 || len(evaluation.PathScores) != 2 { t.Fatalf("explicit path absent: %s", w.Body) } w = call(http.MethodGet, "/v1/trusted?target="+alice, "", serviceKey) @@ -96,6 +104,17 @@ func TestAuthenticatedAPI(t *testing.T) { if strings.Contains(w.Body.String(), "threshold") || strings.Contains(w.Body.String(), "trusted") { t.Fatal("public query exposed private configuration") } + if strings.Contains(w.Body.String(), `"path`) { + t.Fatal("public query exposed an unrequested path") + } + w = call(http.MethodGet, "/v1/reputation?source="+alice+"&target="+bob+"&include_path=true", "", "") + var public trust.Result + if err := json.Unmarshal(w.Body.Bytes(), &public); err != nil { + t.Fatal(err) + } + if len(public.PathScores) != 2 { + t.Fatalf("public path scores absent: %s", w.Body) + } if err := store.Replace(ctx, alice, []domain.Vouch{{Author: alice, Subject: bob, Score: -3}}, time.Now()); err != nil { t.Fatal(err) } diff --git a/internal/domain/lexicon_test.go b/internal/domain/lexicon_test.go new file mode 100644 index 0000000..739a634 --- /dev/null +++ b/internal/domain/lexicon_test.go @@ -0,0 +1,40 @@ +package domain + +import ( + "encoding/json" + "os" + "testing" +) + +func TestVouchLexicon(t *testing.T) { + data, err := os.ReadFile("../../lexicons/computer/ngp/provenance/vouch.json") + if err != nil { + t.Fatal(err) + } + var schema struct { + ID string `json:"id"` + Defs map[string]struct { + Record struct { + Properties map[string]struct { + Minimum int `json:"minimum"` + Maximum int `json:"maximum"` + MaxLength int `json:"maxLength"` + MaxGraphemes int `json:"maxGraphemes"` + } `json:"properties"` + } `json:"record"` + } `json:"defs"` + } + if err := json.Unmarshal(data, &schema); err != nil { + t.Fatal(err) + } + if schema.ID != Collection { + t.Fatalf("schema ID = %q, want %q", schema.ID, Collection) + } + properties := schema.Defs["main"].Record.Properties + if score := properties["score"]; score.Minimum != Blocked || score.Maximum != MaxScore { + t.Fatalf("schema score limits = %+v", score) + } + if tag := properties["tag"]; tag.MaxGraphemes != MaxTagLength { + t.Fatalf("schema tag limit = %+v", tag) + } +} diff --git a/internal/domain/vouch.go b/internal/domain/vouch.go index f18d292..e131f25 100644 --- a/internal/domain/vouch.go +++ b/internal/domain/vouch.go @@ -9,7 +9,7 @@ import ( ) const ( - Collection = "dev.provenance.vouch" + Collection = "computer.ngp.provenance.vouch" Blocked = -3 MinScore = -2 MaxScore = 2 diff --git a/internal/trust/graph.go b/internal/trust/graph.go index 985f388..38ced19 100644 --- a/internal/trust/graph.go +++ b/internal/trust/graph.go @@ -26,9 +26,16 @@ const ( ) type Result struct { - Status Status `json:"status"` - Score *float64 `json:"score"` - Path []string `json:"path,omitempty"` + Status Status `json:"status"` + Score *float64 `json:"score"` + Path []string `json:"path,omitempty"` + PathScores []PathScore `json:"path_scores,omitempty"` +} + +type PathScore struct { + DID string `json:"did"` + Assertion *int `json:"assertion"` + Score float64 `json:"score"` } type Limits struct { @@ -43,9 +50,10 @@ type state struct { } type step struct { - state state - parent int - depth int + state state + parent int + depth int + assertion int } // Select one assertion per pair so that fallback never overrides an explicit score. @@ -82,7 +90,7 @@ func Calculate(ctx context.Context, edges []domain.Vouch, source, target string, return Result{}, errors.New("invalid traversal limits") } if source == target { - return result(SelfStatus, domain.SelfScore, []string{source}), nil + return result(SelfStatus, domain.SelfScore, []PathScore{{DID: source, Score: domain.SelfScore}}), nil } if len(edges) > limits.Edges { return Result{}, ErrLimit @@ -94,7 +102,10 @@ func Calculate(ctx context.Context, edges []domain.Vouch, source, target string, return Result{}, err } if v.Author == source && v.Subject == target && v.Score == domain.Blocked { - return result(BlockedStatus, domain.Blocked, []string{source, target}), nil + return result(BlockedStatus, domain.Blocked, []PathScore{ + {DID: source, Score: domain.SelfScore}, + {DID: target, Assertion: &v.Score, Score: domain.Blocked}, + }), nil } adj[v.Author] = append(adj[v.Author], v) } @@ -105,7 +116,7 @@ func Calculate(ctx context.Context, edges []domain.Vouch, source, target string, // Breadth-first states retain the shortest path for each node and bottleneck score. // Longer paths with the same bottleneck cannot improve the result, including cycles. initial := state{source, domain.MaxScore} - queue := []step{{initial, -1, 0}} + queue := []step{{state: initial, parent: -1}} seen := map[state]bool{initial: true} bestIndex := -1 bestRaw := math.Inf(-1) @@ -135,16 +146,24 @@ func Calculate(ctx context.Context, edges []domain.Vouch, source, target string, return Result{}, ErrLimit } seen[next] = true - queue = append(queue, step{next, head, current.depth + 1}) + queue = append(queue, step{next, head, current.depth + 1, edge.Score}) } } if bestIndex == -1 { return Result{Status: UnknownStatus}, nil } - path := make([]string, 0, queue[bestIndex].depth+1) + // Report prefix scores on the selected path, not independently selected paths to each node. + path := make([]PathScore, 0, queue[bestIndex].depth+1) for index := bestIndex; index >= 0; index = queue[index].parent { - path = append(path, queue[index].state.node) + current := queue[index] + node := PathScore{DID: current.state.node, Score: domain.SelfScore} + if current.depth > 0 { + node.Assertion = ¤t.assertion + raw := float64(current.state.capacity) - limits.Penalty*float64(current.depth-1) + node.Score = math.Max(domain.MinScore, math.Min(domain.MaxScore, raw)) + } + path = append(path, node) } for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 { path[i], path[j] = path[j], path[i] @@ -152,6 +171,10 @@ func Calculate(ctx context.Context, edges []domain.Vouch, source, target string, return result(CalculatedStatus, math.Max(domain.MinScore, math.Min(domain.MaxScore, bestRaw)), path), nil } -func result(status Status, score float64, path []string) Result { - return Result{Status: status, Score: &score, Path: path} +func result(status Status, score float64, nodes []PathScore) Result { + path := make([]string, len(nodes)) + for i, node := range nodes { + path[i] = node.DID + } + return Result{Status: status, Score: &score, Path: path, PathScores: nodes} } diff --git a/internal/trust/graph_test.go b/internal/trust/graph_test.go index 797d636..26b2660 100644 --- a/internal/trust/graph_test.go +++ b/internal/trust/graph_test.go @@ -64,6 +64,48 @@ func TestLimitsAndCancellation(t *testing.T) { } } +func TestPathScores(t *testing.T) { + for _, tt := range []struct { + name string + edges []domain.Vouch + target string + assertions []int + scores []float64 + }{ + {"best path", []domain.Vouch{{Author: "A", Subject: "C", Score: 0}, {Author: "A", Subject: "B", Score: 2}, {Author: "B", Subject: "C", Score: 2}}, "C", []int{2, 2}, []float64{3, 2, 1.9}}, + {"bottleneck", []domain.Vouch{{Author: "A", Subject: "B", Score: 1}, {Author: "B", Subject: "C", Score: 2}}, "C", []int{1, 2}, []float64{3, 1, .9}}, + {"negative", []domain.Vouch{{Author: "A", Subject: "B", Score: -1}, {Author: "B", Subject: "C", Score: 0}}, "C", []int{-1, 0}, []float64{3, -1, -1.1}}, + {"clamped block", []domain.Vouch{{Author: "A", Subject: "B", Score: 2}, {Author: "B", Subject: "C", Score: domain.Blocked}}, "C", []int{2, domain.Blocked}, []float64{3, 2, -2}}, + {"direct block", []domain.Vouch{{Author: "A", Subject: "C", Score: domain.Blocked}}, "C", []int{domain.Blocked}, []float64{3, -3}}, + {"self", nil, "A", nil, []float64{3}}, + {"unknown", nil, "C", nil, nil}, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := Calculate(context.Background(), tt.edges, "A", tt.target, Limits{100, 1000, .1}) + if err != nil { + t.Fatal(err) + } + if len(got.PathScores) != len(tt.scores) { + t.Fatalf("path scores = %+v", got.PathScores) + } + for i, node := range got.PathScores { + if node.DID != got.Path[i] || math.Abs(node.Score-tt.scores[i]) > 1e-9 { + t.Fatalf("node %d = %+v", i, node) + } + if i == 0 { + if node.Assertion != nil { + t.Fatal("source has an incoming assertion") + } + continue + } + if node.Assertion == nil || *node.Assertion != tt.assertions[i-1] { + t.Fatalf("node %d assertion = %v", i, node.Assertion) + } + } + }) + } +} + func TestTagSelection(t *testing.T) { edges := []domain.Vouch{ {Author: "A", Subject: "B", Score: 2}, diff --git a/lexicons/dev.provenance.vouch.json b/lexicons/computer/ngp/provenance/vouch.json similarity index 81% rename from lexicons/dev.provenance.vouch.json rename to lexicons/computer/ngp/provenance/vouch.json index 55b2391..01431ac 100644 --- a/lexicons/dev.provenance.vouch.json +++ b/lexicons/computer/ngp/provenance/vouch.json @@ -1,9 +1,10 @@ { "lexicon": 1, - "id": "dev.provenance.vouch", + "id": "computer.ngp.provenance.vouch", "defs": { "main": { "type": "record", + "description": "A public trust assertion for an account and tag.", "key": "any", "record": { "type": "object", diff --git a/nix/goat.nix b/nix/goat.nix new file mode 100644 index 0000000..049084d --- /dev/null +++ b/nix/goat.nix @@ -0,0 +1,50 @@ +{ + lib, + stdenvNoCC, + fetchurl, +}: +let + version = "0.2.5"; + releases = { + x86_64-linux = { + archive = "Linux_x86_64"; + sha256 = "4d7eaa4e4e300f768038a9e98d30a82b5928f28e54da1addd783ed405c83d9b7"; + }; + aarch64-linux = { + archive = "Linux_arm64"; + sha256 = "6a92e998c093b05dc9bc7390837264f30f60d8e28deb1a59df1928a8a82ae663"; + }; + aarch64-darwin = { + archive = "Darwin_arm64"; + sha256 = "e333bf62bd0526326b7f21c312dc4a6c1c542d882742d170b1ff08bacd2711f7"; + }; + }; + release = releases.${stdenvNoCC.hostPlatform.system}; +in +stdenvNoCC.mkDerivation { + pname = "atproto-goat"; + inherit version; + # Pin upstream binaries until nixpkgs provides this release. + src = fetchurl { + url = "https://github.com/bluesky-social/goat/releases/download/v${version}/goat_${release.archive}.tar.gz"; + inherit (release) sha256; + }; + sourceRoot = "."; + dontConfigure = true; + dontBuild = true; + installPhase = '' + runHook preInstall + install -Dm755 goat "$out/bin/goat" + runHook postInstall + ''; + meta = { + description = "ATProto command-line tools for records and lexicons"; + homepage = "https://github.com/bluesky-social/goat"; + license = with lib.licenses; [ + mit + asl20 + ]; + platforms = builtins.attrNames releases; + mainProgram = "goat"; + }; +} diff --git a/nix/package.nix b/nix/package.nix index 247487e..ba1c248 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -19,6 +19,7 @@ buildGoModule { ../go.sum ../cmd ../internal + ../lexicons ]; }; -- 2.51.2