~bigbes/sr-ht-spec

ref: 51f56da36c777b20e634700cffb29b1e89da9e78 sr-ht-spec/service/read.go -rw-r--r-- 9.3 KiB
51f56da3 — Eugene Blikh bd: clear sync.remote 26 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
package service

import (
	"context"
	"errors"
	"fmt"

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

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

// ApprovedRev is the revision string meaning "the space's approved head". It is
// the empty string so that a caller which simply forwards an absent ?rev= gets
// the approved revision by default, which is the read contract: reads default
// to the approved revision, because serving drafts by default would poison
// every downstream agent context with unreviewed text.
const ApprovedRev = ""

// Document is one document as it exists at a revision.
//
// Blob and Rev are hex object names rather than plumbing.Hash so that api/,
// mcpsrv/, graph/ and web/ can carry them without importing gitx — the layering
// rule is that nothing above service/ touches the git layer, and a leaked
// plumbing type would break it on the first struct field.
type Document struct {
	// Path is the document's path in the tree.
	Path string

	// Blob is the sha of the document's blob — the render cache key. It is
	// content-addressed, so a cache entry keyed by it can never go stale.
	Blob string

	// Rev is the commit the read resolved to. For a read at the approved head
	// this is the value to hand back as the pinned ?rev=, and it is the same
	// value an agent sends as If-Match.
	Rev string

	// Data is the whole document: frontmatter and body.
	Data []byte
}

// ReadDocument reads one document by path, at the approved head when rev is
// ApprovedRev and at a pinned revision otherwise.
//
// This is the same code path for both. There is one storage tier and no
// checkout, so "the approved text of SPEC-0007" and "SPEC-0007 at
// 1f0c1d1a" differ only in which revision is resolved.
func (s *Service) ReadDocument(ctx context.Context, sp *Space, rev, path string) (Document, error) {
	commit, resolved, err := s.resolveRev(ctx, sp, rev)
	if err != nil {
		return Document{}, err
	}
	doc, err := sp.Repo.ReadDocument(ctx, resolved, path)
	if err != nil {
		return Document{}, readErr(err, "read %s at %s in %s", path, resolved, sp.Ref)
	}
	return Document{
		Path: doc.Path,
		Blob: doc.Blob.String(),
		Rev:  commit.String(),
		Data: doc.Data,
	}, nil
}

// ListDocuments returns every document in a space at a revision, in tree order.
//
// Bodies are included: they come off the same tree walk, the volume is tens of
// documents a day, and every caller that lists documents (the indexer, the
// review page, the ID map a push validation builds) needs the frontmatter,
// which is not separable from the blob.
func (s *Service) ListDocuments(ctx context.Context, sp *Space, rev string) ([]Document, error) {
	commit, resolved, err := s.resolveRev(ctx, sp, rev)
	if err != nil {
		return nil, err
	}
	docs, err := sp.Repo.ListDocuments(ctx, resolved)
	if err != nil {
		return nil, readErr(err, "list documents at %s in %s", resolved, sp.Ref)
	}
	out := make([]Document, 0, len(docs))
	for _, d := range docs {
		out = append(out, Document{
			Path: d.Path,
			Blob: d.Blob.String(),
			Rev:  commit.String(),
			Data: d.Data,
		})
	}
	return out, nil
}

// Policy reads the space's effective .spec.yml at a revision.
//
// A space with no .spec.yml gets core.DefaultPolicy: the house frontmatter
// contract and nothing auto-merged. That is the fail-closed direction — a space
// that has not said anything about review must not be quietly laundering
// unreviewed agent output onto the approved branch — and it is a defined
// default rather than a fallback, which is why an absent file is not an error
// but an unparseable one is.
//
// Reading it at a revision rather than from configuration is what makes policy
// changes reviewable like any other change, and it is why a push that edits
// .spec.yml is validated against the policy it is installing.
func (s *Service) Policy(ctx context.Context, sp *Space, rev string) (core.Policy, error) {
	_, resolved, err := s.resolveRev(ctx, sp, rev)
	if err != nil {
		return core.Policy{}, err
	}
	data, _, err := sp.Repo.ReadBlob(ctx, resolved, core.PolicyFile)
	if err != nil {
		if errors.Is(err, gitx.ErrNotFound) {
			return core.DefaultPolicy(), nil
		}
		return core.Policy{}, readErr(err, "read %s at %s in %s",
			core.PolicyFile, resolved, sp.Ref)
	}
	pol, err := core.ParsePolicy(data)
	if err != nil {
		return core.Policy{}, fmt.Errorf("service: %s at %s in %s: %w",
			core.PolicyFile, resolved, sp.Ref, err)
	}
	return pol, nil
}

// ResolveRev resolves a revision string against a space, returning the commit
// it names as a hex object name. ApprovedRev resolves to the approved head.
//
// Callers use it to pin: the review UI turns "the approved head right now" into
// an immutable ?rev= before it renders anything, so a merge landing mid-render
// cannot make one page describe two revisions.
func (s *Service) ResolveRev(ctx context.Context, sp *Space, rev string) (string, error) {
	commit, _, err := s.resolveRev(ctx, sp, rev)
	if err != nil {
		return "", err
	}
	return commit.String(), nil
}

// resolveRev turns a caller's revision string into both the commit it names and
// the string to pass back down to gitx.
//
// Both are returned because they are not interchangeable: the hash is what a
// caller pins and compares, while the original string is what the read is
// issued against. Re-issuing reads against the resolved hash instead would be
// one extra object lookup per read for no gain, and would lose the branch name
// from error messages.
// ReadDocumentAtRef reads a document at an arbitrary ref, bypassing the read
// contract's object-name requirement.
//
// This is the review path's entry point: rendering and diffing a proposal
// branch genuinely needs to read one. It is deliberately a separate,
// awkwardly-named method rather than a flag on ReadDocument, so that serving
// unreviewed content is something a caller has to ask for by name and a reviewer
// can grep for — never something a read surface can be talked into by a crafted
// rev parameter.
//
// Do not call this from any surface that answers "read SPEC-0007".
func (s *Service) ReadDocumentAtRef(ctx context.Context, sp *Space, ref, path string) (Document, error) {
	if sp == nil || sp.Repo == nil {
		return Document{}, errors.New("service: space has no open repository")
	}
	resolved := ref
	if resolved == ApprovedRev {
		resolved = sp.Repo.ApprovedBranch()
	}
	commit, err := sp.Repo.ResolveRev(ctx, resolved)
	if err != nil {
		return Document{}, readErr(err, "resolve revision %q in %s", resolved, sp.Ref)
	}
	d, err := sp.Repo.ReadDocument(ctx, resolved, path)
	if err != nil {
		return Document{}, readErr(err, "read %s at %s in %s", path, resolved, sp.Ref)
	}
	return Document{Path: d.Path, Blob: d.Blob.String(), Rev: commit.String(), Data: d.Data}, nil
}

func (s *Service) resolveRev(ctx context.Context, sp *Space, rev string) (plumbing.Hash, string, error) {
	if sp == nil || sp.Repo == nil {
		return plumbing.ZeroHash, "", errors.New("service: space has no open repository")
	}
	if err := ValidateReadRev(rev); err != nil {
		// Wrapped as ErrNotFound so a crafted revision cannot tell "malformed"
		// from "absent" by probing, matching readErr's existing choice.
		// ErrBadReadRev stays in the chain for logs and for callers that care.
		return plumbing.ZeroHash, "", fmt.Errorf("%w: %w", ErrNotFound, err)
	}
	resolved := rev
	if resolved == ApprovedRev {
		resolved = sp.Repo.ApprovedBranch()
	}
	commit, err := sp.Repo.ResolveRev(ctx, resolved)
	if err != nil {
		return plumbing.ZeroHash, "", readErr(err, "resolve revision %q in %s", resolved, sp.Ref)
	}
	return commit, resolved, nil
}

// ValidateReadRev enforces the read contract: the read plane serves the approved
// head by default, and otherwise only an immutable object name.
//
// gitx.ResolveRev happily resolves ref names, so without this guard a caller
// could pass rev="proposals/42" and have the READ plane hand back unreviewed
// proposal content — the single failure this service exists to prevent, since
// that text would then flow into agent context as though it were approved.
// Reading a proposal branch is a deliberate act belonging to the review path,
// not something any read surface can be talked into.
//
// Object names are required to be full: an abbreviation that is unique today
// can become ambiguous later, so a pinned revision would silently stop meaning
// one thing.
func ValidateReadRev(rev string) error {
	if rev == ApprovedRev {
		return nil
	}
	if len(rev) != 40 {
		return fmt.Errorf("%w: revision %q must be a full 40-character object name", ErrBadReadRev, rev)
	}
	for _, c := range rev {
		if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
			return fmt.Errorf("%w: revision %q must be a full 40-character object name", ErrBadReadRev, rev)
		}
	}
	return nil
}

// readErr maps a gitx failure onto this package's sentinels so callers above
// service/ can branch on it without importing gitx. ErrNotFound and ErrBadRev
// both become ErrNotFound at this boundary — a crafted revision must not be
// able to tell "malformed" from "absent" by probing — while the original class
// stays in the chain for logs and for gitx-aware callers.
func readErr(err error, format string, args ...any) error {
	what := fmt.Sprintf(format, args...)
	switch {
	case errors.Is(err, gitx.ErrNotFound), errors.Is(err, gitx.ErrBadRev):
		return fmt.Errorf("%w: %s: %w", ErrNotFound, what, err)
	default:
		return fmt.Errorf("service: %s: %w", what, err)
	}
}