feat(capture): CaptureService use-case + ports + entities (#51)
The Clean-Architecture core of the capture capability (#49b). Pure orchestration over ports — no HTTP, no live Gitea, no audit I/O — fully unit-tested against fakes before any adapter exists. - Ports: BrainStore (#45 write/update/get), IssueTracker, SummaryWriter, ClassificationPolicy (satisfied by #50's classification.Config), AuditSink. Entities: Insight, Ticket, Summary, CaptureContext, CaptureInput, CaptureReceipt. - CaptureService.Capture: validate-before-write (fail-closed), resolve effective classification (stricter of declared vs target-derived; under-declaration logged as a security event), orchestrate insights (write/supersede) → tickets → summary best-effort, emit a request-level audit record of exactly what landed, return a partial-aware receipt. - dry_run short-circuits after validation, writes nothing (not even audit). Out of scope here, layered on later: the I1 origin sovereignty gate (#53, needs the server-derived principal) and the classification-aware audit degradation/refusal (#54). "Effective" is folded into the service as classification.Stricter rather than a port method — the stricter-wins rule is use-case policy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- fakes ---
|
||||
|
||||
type fakeBrain struct {
|
||||
writes []Note
|
||||
updates []Note
|
||||
gets []string
|
||||
failOn func(Note) error // nil = always succeed
|
||||
hashSeq int
|
||||
}
|
||||
|
||||
func (f *fakeBrain) ref(prefix string, n Note, superseded bool) Ref {
|
||||
f.hashSeq++
|
||||
path := "wiki/" + n.Wing + "/" + n.Hall + "/" + n.Filename + ".md"
|
||||
return Ref{ID: path, Path: path, ContentHash: prefix + string(rune('0'+f.hashSeq)), Superseded: superseded}
|
||||
}
|
||||
|
||||
func (f *fakeBrain) Write(_ context.Context, n Note) (Ref, error) {
|
||||
if f.failOn != nil {
|
||||
if err := f.failOn(n); err != nil {
|
||||
return Ref{}, err
|
||||
}
|
||||
}
|
||||
f.writes = append(f.writes, n)
|
||||
return f.ref("w", n, false), nil
|
||||
}
|
||||
|
||||
func (f *fakeBrain) Update(_ context.Context, slug string, n Note) (Ref, error) {
|
||||
if f.failOn != nil {
|
||||
if err := f.failOn(n); err != nil {
|
||||
return Ref{}, err
|
||||
}
|
||||
}
|
||||
n.Filename = slug
|
||||
f.updates = append(f.updates, n)
|
||||
return f.ref("u", n, true), nil
|
||||
}
|
||||
|
||||
func (f *fakeBrain) Get(_ context.Context, id string) (StoredNote, error) {
|
||||
f.gets = append(f.gets, id)
|
||||
return StoredNote{ID: id, Path: id}, nil
|
||||
}
|
||||
|
||||
type fakeTracker struct {
|
||||
created []string
|
||||
closed []int
|
||||
comments []int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeTracker) CreateIssue(_ context.Context, repo, title, _ string) (IssueRef, error) {
|
||||
if f.err != nil {
|
||||
return IssueRef{}, f.err
|
||||
}
|
||||
f.created = append(f.created, repo+":"+title)
|
||||
return IssueRef{Repo: repo, Number: 100 + len(f.created), URL: "https://git/" + repo + "/issues/x"}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTracker) CloseIssue(_ context.Context, repo string, number int) (IssueRef, error) {
|
||||
if f.err != nil {
|
||||
return IssueRef{}, f.err
|
||||
}
|
||||
f.closed = append(f.closed, number)
|
||||
return IssueRef{Repo: repo, Number: number}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTracker) CommentIssue(_ context.Context, repo string, number int, _ string) (IssueRef, error) {
|
||||
if f.err != nil {
|
||||
return IssueRef{}, f.err
|
||||
}
|
||||
f.comments = append(f.comments, number)
|
||||
return IssueRef{Repo: repo, Number: number}, nil
|
||||
}
|
||||
|
||||
type fakeSummary struct {
|
||||
paths []string
|
||||
content []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSummary) WriteFile(_ context.Context, _, path, content string) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.paths = append(f.paths, path)
|
||||
f.content = append(f.content, content)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakePolicy derives from an explicit map; default Internal so tests pin
|
||||
// behaviour without depending on the real defaulting.
|
||||
type fakePolicy struct{ tags map[string]classification.Level }
|
||||
|
||||
func (p fakePolicy) Derive(t classification.Target) classification.Level {
|
||||
if lvl, ok := p.tags[t.Name]; ok {
|
||||
return lvl
|
||||
}
|
||||
return classification.Internal
|
||||
}
|
||||
|
||||
type fakeAudit struct {
|
||||
entries []AuditEntry
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeAudit) Record(_ context.Context, e AuditEntry) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.entries = append(f.entries, e)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func newSvc(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
|
||||
s := NewService(b, tr, sw, p, a)
|
||||
s.now = func() time.Time { return time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC) }
|
||||
return s
|
||||
}
|
||||
|
||||
func baseCtx() CaptureContext {
|
||||
return CaptureContext{Harness: "claude-code", Actor: "mathias", Principal: "mathias", Classification: "internal"}
|
||||
}
|
||||
|
||||
// --- scenarios ---
|
||||
|
||||
func TestCaptureHappyPath(t *testing.T) {
|
||||
b := &fakeBrain{}
|
||||
tr := &fakeTracker{}
|
||||
au := &fakeAudit{}
|
||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
||||
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
Insights: []Insight{
|
||||
{Text: "a", Wing: "hyperguild", Hall: "decisions", SupersedeSlug: ""},
|
||||
{Text: "b", Wing: "hyperguild", Hall: "facts"},
|
||||
},
|
||||
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "do x", Body: "y"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rec.Insights, 2)
|
||||
for _, r := range rec.Insights {
|
||||
assert.True(t, r.OK)
|
||||
assert.NotEmpty(t, r.ContentHash, "read-after-write hash returned")
|
||||
}
|
||||
require.Len(t, rec.Tickets, 1)
|
||||
assert.True(t, rec.Tickets[0].OK)
|
||||
assert.Equal(t, 2, len(b.writes))
|
||||
assert.Empty(t, rec.Errors)
|
||||
// Audit emitted naming principal/harness + items that landed.
|
||||
require.Len(t, au.entries, 1)
|
||||
assert.Equal(t, "mathias", au.entries[0].Principal)
|
||||
assert.Equal(t, "claude-code", au.entries[0].Harness)
|
||||
assert.Len(t, au.entries[0].Items, 3)
|
||||
}
|
||||
|
||||
func TestCaptureSupersedeNotDuplicate(t *testing.T) {
|
||||
b := &fakeBrain{}
|
||||
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
||||
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
Insights: []Insight{{Text: "revised", Wing: "hyperguild", Hall: "facts", SupersedeSlug: "prior-note"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, b.writes, "supersede must not create")
|
||||
require.Len(t, b.updates, 1)
|
||||
assert.Equal(t, "prior-note", b.updates[0].Filename)
|
||||
assert.True(t, rec.Insights[0].Superseded)
|
||||
}
|
||||
|
||||
func TestCaptureValidationFailClosed(t *testing.T) {
|
||||
b := &fakeBrain{}
|
||||
tr := &fakeTracker{}
|
||||
au := &fakeAudit{}
|
||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
||||
|
||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
Insights: []Insight{
|
||||
{Text: "ok", Wing: "hyperguild", Hall: "facts"},
|
||||
{Text: "bad", Wing: "hyperguild", Hall: "garbage-hall"}, // invalid hall
|
||||
},
|
||||
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}},
|
||||
})
|
||||
require.Error(t, err)
|
||||
// Nothing written anywhere.
|
||||
assert.Empty(t, b.writes)
|
||||
assert.Empty(t, b.updates)
|
||||
assert.Empty(t, tr.created)
|
||||
assert.Empty(t, au.entries)
|
||||
}
|
||||
|
||||
func TestCaptureValidationRejectsBadTicket(t *testing.T) {
|
||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
Tickets: []Ticket{{Repo: "hyperguild", Action: "frobnicate"}}, // bad action
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
Tickets: []Ticket{{Repo: "hyperguild", Action: "close"}}, // close needs number
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCapturePartialFailureBestEffort(t *testing.T) {
|
||||
b := &fakeBrain{failOn: func(n Note) error {
|
||||
if strings.Contains(n.Content, "FAIL") {
|
||||
return errors.New("disk full")
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
tr := &fakeTracker{}
|
||||
au := &fakeAudit{}
|
||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
||||
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
Insights: []Insight{
|
||||
{Text: "good one", Wing: "hyperguild", Hall: "facts"},
|
||||
{Text: "FAIL here", Wing: "hyperguild", Hall: "facts"},
|
||||
},
|
||||
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}},
|
||||
})
|
||||
require.NoError(t, err, "partial failure is not a request-level error")
|
||||
assert.True(t, rec.Insights[0].OK)
|
||||
assert.False(t, rec.Insights[1].OK)
|
||||
assert.True(t, rec.Tickets[0].OK, "ticket still persisted; no rollback")
|
||||
require.Len(t, rec.Errors, 1)
|
||||
assert.Equal(t, "insight[1]", rec.Errors[0].Item)
|
||||
// Audit reflects exactly what landed: 1 insight + 1 ticket.
|
||||
require.Len(t, au.entries, 1)
|
||||
assert.Len(t, au.entries[0].Items, 2)
|
||||
}
|
||||
|
||||
func TestCaptureDryRunWritesNothing(t *testing.T) {
|
||||
b := &fakeBrain{}
|
||||
tr := &fakeTracker{}
|
||||
au := &fakeAudit{}
|
||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
||||
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
DryRun: true,
|
||||
Insights: []Insight{{Text: "a", Wing: "hyperguild", Hall: "facts"}},
|
||||
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, rec.DryRun)
|
||||
assert.Len(t, rec.Insights, 1)
|
||||
assert.True(t, rec.Insights[0].OK, "would-be receipt marks planned items ok")
|
||||
// Nothing written anywhere, including audit.
|
||||
assert.Empty(t, b.writes)
|
||||
assert.Empty(t, tr.created)
|
||||
assert.Empty(t, au.entries)
|
||||
}
|
||||
|
||||
func TestCaptureStricterClassificationWins(t *testing.T) {
|
||||
// Caller declares internal; target wing tagged confidential → effective confidential + security event.
|
||||
b := &fakeBrain{}
|
||||
au := &fakeAudit{}
|
||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||
svc := newSvc(b, &fakeTracker{}, nil, pol, au)
|
||||
|
||||
ctx := baseCtx()
|
||||
ctx.Classification = "internal"
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: ctx,
|
||||
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "confidential", rec.EffectiveClassification)
|
||||
require.Len(t, au.entries, 1)
|
||||
assert.NotEmpty(t, au.entries[0].SecurityEvents, "under-declaration logged as security event")
|
||||
assert.Equal(t, "confidential", au.entries[0].EffectiveClassification)
|
||||
}
|
||||
|
||||
func TestCaptureCallerRaisingSensitivityHonoured(t *testing.T) {
|
||||
// Caller declares confidential; target internal → effective confidential, NOT a security event.
|
||||
au := &fakeAudit{}
|
||||
pol := fakePolicy{tags: map[string]classification.Level{"hyperguild": classification.Internal}}
|
||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au)
|
||||
|
||||
ctx := baseCtx()
|
||||
ctx.Classification = "confidential"
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: ctx,
|
||||
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "confidential", rec.EffectiveClassification)
|
||||
assert.Empty(t, au.entries[0].SecurityEvents, "raising sensitivity is honoured, not flagged")
|
||||
}
|
||||
|
||||
func TestCaptureSummaryPathAndFidelity(t *testing.T) {
|
||||
sw := &fakeSummary{}
|
||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, sw, fakePolicy{}, &fakeAudit{})
|
||||
|
||||
ctx := baseCtx()
|
||||
ctx.Fidelity = "transcript-parse"
|
||||
ctx.SessionRef = "abc123def456"
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: ctx,
|
||||
Summary: &Summary{Title: "Session Wrap", Body: "did stuff", ReposTouched: []string{"hyperguild"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rec.Summary)
|
||||
assert.True(t, rec.Summary.OK)
|
||||
require.Len(t, sw.paths, 1)
|
||||
assert.True(t, strings.HasPrefix(sw.paths[0], "summaries/claude-code/2026-06/"), "path: %s", sw.paths[0])
|
||||
assert.Contains(t, sw.paths[0], "session-wrap")
|
||||
assert.Contains(t, sw.content[0], "fidelity: transcript-parse", "fidelity stamped in frontmatter")
|
||||
}
|
||||
Reference in New Issue
Block a user