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 ]; };