~bigbes/sr-ht-spec

29370c57a37881e83ccd640122126d22f87eb720 — Eugene Blikh 27 days ago e5927f5
refactor: one derivation of a proposal's branch name, in core

db.ProposalBranch and gitx.ProposalBranch each spelled out "proposals/" +
id with different signatures and different error behaviour: db formatted
anything including 0, gitx refused a non-positive id. Two derivations of
one name is a proposal whose row and whose ref can disagree, which is
painful to trace and cheap to prevent.

core now owns ProposalPrefix and ProposalBranch(int64) (string, error).
gitx.ProposalPrefix and db.BranchPrefix are that constant, gitx wraps
core's error in ErrBadRev so its callers keep their failure class, and
db.ProposalBranch delegates — inheriting core's refusal of a non-positive
id, which is why its signature grew an error.
M core/errors.go => core/errors.go +5 -0
@@ 72,4 72,9 @@ var (

	// ErrInvalidApproval is returned for an approval kind outside human/policy.
	ErrInvalidApproval = errors.New("invalid approval kind")

	// ErrInvalidProposalID is returned for a proposal id that cannot name a
	// proposal: ids are Postgres sequence values starting at 1, so anything
	// else is an unwritten row or an unset field rather than a proposal.
	ErrInvalidProposalID = errors.New("invalid proposal id")
)

M core/proposal.go => core/proposal.go +31 -1
@@ 1,6 1,36 @@
package core

import "fmt"
import (
	"fmt"
	"strconv"
)

// ProposalPrefix is the ref namespace a proposal branch lives under, and the
// only namespace an agent credential may write. The refs rule — an agent token
// can only update refs under this prefix, and only the owner can move the
// approved branch — is the boundary that actually bounds the damage a confused
// agent can do.
//
// It lives here rather than in gitx or db because both derive branch names from
// it and a proposal whose row and whose ref disagree about its name is a break
// with no cheap way to trace it.
const ProposalPrefix = "proposals/"

// ProposalBranch is the branch name for a proposal id: "proposals/42". The id
// is the proposal row's primary key, which is what the branch, the row and the
// stable proposal URL share — so this is the one derivation, and gitx and db
// both call it rather than each spelling the concatenation out.
//
// A non-positive id is refused rather than formatted: ids come from a Postgres
// sequence and start at 1, so a zero is an unwritten row or an unset field, and
// "proposals/0" is a branch name that would go on to be created, pushed and
// looked for.
func ProposalBranch(id int64) (string, error) {
	if id <= 0 {
		return "", fmt.Errorf("%w: proposal id %d must be positive", ErrInvalidProposalID, id)
	}
	return ProposalPrefix + strconv.FormatInt(id, 10), nil
}

// ProposalState is the lifecycle of a proposal.
//

M core/proposal_test.go => core/proposal_test.go +31 -0
@@ 5,6 5,37 @@ import (
	"testing"
)

// The one derivation of a proposal's branch name. gitx cuts the ref from it and
// db records it on the row; if the two ever computed it separately, a proposal
// whose row and ref disagreed would be a break with no cheap way to trace it.
func TestProposalBranch(t *testing.T) {
	for _, tc := range []struct {
		id   int64
		want string
	}{
		{1, "proposals/1"},
		{42, "proposals/42"},
		{1000000, "proposals/1000000"},
	} {
		got, err := ProposalBranch(tc.id)
		if err != nil {
			t.Errorf("ProposalBranch(%d): %v", tc.id, err)
			continue
		}
		if got != tc.want {
			t.Errorf("ProposalBranch(%d) = %q, want %q", tc.id, got, tc.want)
		}
	}
	// Ids come from a sequence starting at 1, so a non-positive one is an
	// unwritten row, not a proposal — and "proposals/0" is a name that would go
	// on to be created and looked for.
	for _, id := range []int64{0, -1} {
		if _, err := ProposalBranch(id); !errors.Is(err, ErrInvalidProposalID) {
			t.Errorf("ProposalBranch(%d) err = %v, want ErrInvalidProposalID", id, err)
		}
	}
}

func TestParseProposalState(t *testing.T) {
	tests := []struct {
		in string

M db/proposal.go => db/proposal.go +11 -7
@@ 5,22 5,26 @@ import (
	"database/sql"
	"errors"
	"fmt"
	"strconv"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
)

// BranchPrefix is the ref namespace agents may write. The refs rule — an agent
// token can only update refs under this prefix, and only the owner can move the
// approved branch — is the boundary that actually bounds the damage a confused
// agent can do.
const BranchPrefix = "proposals/"
// BranchPrefix is the ref namespace agents may write, under this package's
// name. It is core's constant: the prefix the INSERT below builds a branch from
// and the prefix gitx enforces on a push are one value, because a row and a ref
// that disagree about a proposal's branch name is a break that surfaces as a
// proposal nobody can find.
const BranchPrefix = core.ProposalPrefix

// ProposalBranch is the branch name for a proposal id: "proposals/42". The row
// stores it verbatim (proposal.branch) because the row, not this function, is
// what the reconciler compares against the refs it finds.
func ProposalBranch(id int) string { return BranchPrefix + strconv.Itoa(id) }
//
// The derivation is core.ProposalBranch, so this and gitx.ProposalBranch cannot
// disagree — and it inherits core's refusal of a non-positive id, which here
// means an unwritten row rather than a proposal.
func ProposalBranch(id int) (string, error) { return core.ProposalBranch(int64(id)) }

// Proposal is a bundle of document edits awaiting review: a branch under
// BranchPrefix plus this row.

M db/proposal_test.go => db/proposal_test.go +6 -2
@@ 19,8 19,12 @@ func TestProposalOpenAndRead(t *testing.T) {
	if p.ID == 0 {
		t.Fatal("expected a non-zero proposal id")
	}
	if p.Branch != ProposalBranch(p.ID) {
		t.Fatalf("branch = %q, want %q", p.Branch, ProposalBranch(p.ID))
	branch, err := ProposalBranch(p.ID)
	if err != nil {
		t.Fatalf("ProposalBranch(%d): %v", p.ID, err)
	}
	if p.Branch != branch {
		t.Fatalf("branch = %q, want %q", p.Branch, branch)
	}
	if p.State != core.StateOpen {
		t.Fatalf("state = %q, want open", p.State)

M db/unit_test.go => db/unit_test.go +30 -2
@@ 30,13 30,41 @@ func TestProposalBranch(t *testing.T) {
		{42, "proposals/42"},
		{1000000, "proposals/1000000"},
	} {
		if got := ProposalBranch(tc.id); got != tc.want {
		got, err := ProposalBranch(tc.id)
		if err != nil {
			t.Errorf("ProposalBranch(%d): %v", tc.id, err)
			continue
		}
		if got != tc.want {
			t.Errorf("ProposalBranch(%d) = %q, want %q", tc.id, got, tc.want)
		}
	}
	if !strings.HasPrefix(ProposalBranch(7), BranchPrefix) {
	seven, err := ProposalBranch(7)
	if err != nil {
		t.Fatalf("ProposalBranch(7): %v", err)
	}
	if !strings.HasPrefix(seven, BranchPrefix) {
		t.Errorf("ProposalBranch must stay under the refs-rule prefix %q", BranchPrefix)
	}
	// An id no row can have names no branch. "proposals/0" would otherwise be
	// created, pushed and looked for.
	for _, id := range []int{0, -1} {
		if _, err := ProposalBranch(id); !errors.Is(err, core.ErrInvalidProposalID) {
			t.Errorf("ProposalBranch(%d) err = %v, want ErrInvalidProposalID", id, err)
		}
	}
	// One derivation: db and gitx must not be able to disagree.
	fromCore, err := core.ProposalBranch(42)
	if err != nil {
		t.Fatalf("core.ProposalBranch(42): %v", err)
	}
	mine, err := ProposalBranch(42)
	if err != nil {
		t.Fatalf("ProposalBranch(42): %v", err)
	}
	if mine != fromCore {
		t.Errorf("db derives %q where core derives %q", mine, fromCore)
	}
}

func TestHashTokenAndMatches(t *testing.T) {

M gitx/gitx_test.go => gitx/gitx_test.go +9 -0
@@ 280,6 280,15 @@ func TestProposalBranchNaming(t *testing.T) {
	}
	if _, err := ProposalBranch(0); err == nil {
		t.Fatal("ProposalBranch(0) succeeded")
	} else if !errors.Is(err, ErrBadRev) || !errors.Is(err, core.ErrInvalidProposalID) {
		t.Fatalf("ProposalBranch(0) err = %v, want both ErrBadRev and core.ErrInvalidProposalID", err)
	}
	// One derivation, shared with db: this package only adds its failure class.
	if fromCore, err := core.ProposalBranch(42); err != nil || fromCore != b {
		t.Fatalf("core.ProposalBranch(42) = %q, %v; gitx derives %q", fromCore, err, b)
	}
	if ProposalPrefix != core.ProposalPrefix {
		t.Fatalf("ProposalPrefix = %q, core says %q", ProposalPrefix, core.ProposalPrefix)
	}
	if id, ok := ParseProposalBranch("proposals/42"); !ok || id != 42 {
		t.Fatalf("ParseProposalBranch = %d, %v", id, ok)

M gitx/refsrule.go => gitx/refsrule.go +18 -8
@@ 7,10 7,15 @@ import (
	"unicode/utf8"

	"github.com/go-git/go-git/v5/plumbing"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
)

// ProposalPrefix is the namespace agents may write and nothing else.
const ProposalPrefix = "proposals/"
// ProposalPrefix is the namespace agents may write and nothing else. It is
// core's constant under this package's name: the prefix a ref is checked
// against here and the prefix a proposal row's branch is built from are one
// value, or the two disagree the day one of them is edited.
const ProposalPrefix = core.ProposalPrefix

// branchRefPrefix is the only ref namespace a space repository uses. Tags and
// notes are not part of the model, and a branch outside these two namespaces


@@ 147,14 152,19 @@ func IsProposalBranch(branch string) bool {
	return strings.TrimPrefix(branch, ProposalPrefix) != ""
}

// ProposalBranch is the branch name for a proposal id, "proposals/42". The id
// is the proposal row's primary key, which is what the branch and the stable
// proposal URL share.
// ProposalBranch is the branch name for a proposal id, "proposals/42".
//
// The derivation is core's, so a branch cut here and a branch recorded on the
// proposal row cannot drift apart. What this wrapper adds is the failure class
// gitx callers branch on: an id that names no proposal is a bad revision here,
// exactly like a malformed ref, and core's ErrInvalidProposalID stays in the
// chain for a caller that wants to tell the two apart.
func ProposalBranch(id int64) (string, error) {
	if id <= 0 {
		return "", fmt.Errorf("%w: proposal id %d must be positive", ErrBadRev, id)
	branch, err := core.ProposalBranch(id)
	if err != nil {
		return "", fmt.Errorf("%w: %w", ErrBadRev, err)
	}
	return ProposalPrefix + strconv.FormatInt(id, 10), nil
	return branch, nil
}

// ParseProposalBranch recovers the proposal id from a branch name produced by