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,111 @@
|
||||
// Package capture is the Clean-Architecture use-case for the uniform
|
||||
// capture capability (issue #49/#51): persist a finished session's
|
||||
// valuable output — insights → brain, action items → Gitea tickets,
|
||||
// optional summary → ai-sessions — with one invocation, identical core
|
||||
// behaviour across every harness.
|
||||
//
|
||||
// This package is pure orchestration. It depends only on ports
|
||||
// (interfaces) and plain entities — no HTTP, no live Gitea, no embedding
|
||||
// or audit I/O. The real adapters are wired in #52 (Gitea tracker), #53
|
||||
// (REST + I1 origin gate), and #54/#55 (audit path + relay). The I1
|
||||
// sovereignty refusal and the classification-aware audit degradation are
|
||||
// deliberately NOT here — those need the server-derived principal origin
|
||||
// (#53) and the loki/buffer machinery (#54). What lives here is everything
|
||||
// testable against fakes: validation, effective-classification resolution
|
||||
// (stricter wins), best-effort orchestration, and the partial receipt.
|
||||
package capture
|
||||
|
||||
// CaptureContext is the per-session metadata accompanying a capture.
|
||||
//
|
||||
// Classification is the caller-declared sensitivity (model C, spec §4.1):
|
||||
// the server independently derives the target's classification and gates
|
||||
// on the stricter of the two. Principal is server-derived from the
|
||||
// authenticated identity (#53 populates it); it is never caller-asserted.
|
||||
// Harness is descriptive telemetry only — never a gate input.
|
||||
type CaptureContext struct {
|
||||
Harness string
|
||||
SessionRef string
|
||||
Fidelity string
|
||||
Actor string
|
||||
Classification string // caller-declared level token ("" = unspecified)
|
||||
Principal string // server-derived (auth); audit identity
|
||||
}
|
||||
|
||||
// Insight is one piece of session knowledge bound for the brain. A
|
||||
// non-empty SupersedeSlug routes to Update (revise in place); otherwise
|
||||
// Write (create).
|
||||
type Insight struct {
|
||||
Text string
|
||||
Wing string
|
||||
Hall string
|
||||
SupersedeSlug string
|
||||
}
|
||||
|
||||
// Ticket is one action item bound for a Gitea repo. Owner is always the
|
||||
// operator (set by the tracker adapter), never carried here.
|
||||
type Ticket struct {
|
||||
Repo string
|
||||
Action string // create | close | comment
|
||||
Number int // required for close/comment
|
||||
Title string // required for create
|
||||
Body string
|
||||
}
|
||||
|
||||
// Summary is an optional session summary bound for ai-sessions.
|
||||
type Summary struct {
|
||||
Title string
|
||||
Body string
|
||||
ReposTouched []string
|
||||
}
|
||||
|
||||
// CaptureInput is the whole capture request.
|
||||
type CaptureInput struct {
|
||||
Context CaptureContext
|
||||
Insights []Insight
|
||||
Tickets []Ticket
|
||||
Summary *Summary
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// InsightResult is the per-insight outcome in the receipt.
|
||||
type InsightResult struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
ContentHash string `json:"content_hash,omitempty"`
|
||||
Superseded bool `json:"superseded"`
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
// TicketResult is the per-ticket outcome in the receipt.
|
||||
type TicketResult struct {
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number,omitempty"`
|
||||
Action string `json:"action"`
|
||||
URL string `json:"url,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
// SummaryResult is the summary outcome in the receipt.
|
||||
type SummaryResult struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
// ItemError pins a failure to a specific request item for the partial
|
||||
// receipt. Item is a stable locator like "insight[1]" or "ticket[0]".
|
||||
type ItemError struct {
|
||||
Item string `json:"item"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// CaptureReceipt is the structured, partial-aware result. Per-item ok
|
||||
// flags plus a flat Errors list make partial success explicit; the
|
||||
// caller never has to infer what landed.
|
||||
type CaptureReceipt struct {
|
||||
Insights []InsightResult `json:"insights"`
|
||||
Tickets []TicketResult `json:"tickets"`
|
||||
Summary *SummaryResult `json:"summary,omitempty"`
|
||||
Errors []ItemError `json:"errors"`
|
||||
EffectiveClassification string `json:"effective_classification,omitempty"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
)
|
||||
|
||||
// Ref is the read-after-write handle returned by a brain write/update —
|
||||
// the #45 contract. ContentHash lets the caller confirm what landed
|
||||
// without a re-query; for an Update, Superseded is true.
|
||||
type Ref struct {
|
||||
ID string
|
||||
Path string
|
||||
ContentHash string
|
||||
Superseded bool
|
||||
}
|
||||
|
||||
// StoredNote is a brain note fetched by Get: the read-after-write
|
||||
// confirmation primitive (a direct fetch, never a semantic query).
|
||||
type StoredNote struct {
|
||||
ID string
|
||||
Path string
|
||||
ContentHash string
|
||||
Frontmatter map[string]string
|
||||
Body string
|
||||
}
|
||||
|
||||
// Note is the brain-write payload. It carries both the wing/hall taxonomy
|
||||
// and the legacy type/domain fields so a single BrainStore serves both
|
||||
// capture insights and the existing MCP brain_write surface. Reason is
|
||||
// the supersede rationale, used only by Update.
|
||||
type Note struct {
|
||||
Content string
|
||||
Filename string
|
||||
Wing string
|
||||
Hall string
|
||||
Type string
|
||||
Domain string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// BrainStore is the brain persistence port — the shared implementation of
|
||||
// the #45 write/update/get verbs that both the MCP handlers and capture
|
||||
// call, so there is one implementation, not two. The read-after-write +
|
||||
// staleness discipline lives behind this interface so no caller carries
|
||||
// the rule.
|
||||
type BrainStore interface {
|
||||
Write(ctx context.Context, n Note) (Ref, error)
|
||||
Update(ctx context.Context, slug string, n Note) (Ref, error)
|
||||
Get(ctx context.Context, id string) (StoredNote, error)
|
||||
}
|
||||
|
||||
// IssueRef identifies a ticket touched by the tracker.
|
||||
type IssueRef struct {
|
||||
Repo string
|
||||
Number int
|
||||
URL string
|
||||
}
|
||||
|
||||
// IssueTracker is the Gitea ticket port. The implementation (#52) always
|
||||
// scopes to owner "mathias"; the port deliberately omits owner.
|
||||
type IssueTracker interface {
|
||||
CreateIssue(ctx context.Context, repo, title, body string) (IssueRef, error)
|
||||
CloseIssue(ctx context.Context, repo string, number int) (IssueRef, error)
|
||||
CommentIssue(ctx context.Context, repo string, number int, body string) (IssueRef, error)
|
||||
}
|
||||
|
||||
// SummaryWriter is the ai-sessions summary port.
|
||||
type SummaryWriter interface {
|
||||
WriteFile(ctx context.Context, repo, path, content string) error
|
||||
}
|
||||
|
||||
// ClassificationPolicy derives a target's sensitivity (model C). The
|
||||
// "stricter wins" combination of declared vs derived is use-case policy
|
||||
// and lives in the service, so the port stays minimal. Satisfied by
|
||||
// classification.Config (#50).
|
||||
type ClassificationPolicy interface {
|
||||
Derive(target classification.Target) classification.Level
|
||||
}
|
||||
|
||||
// AuditEntry is the request-level audit record (I5): who/what captured
|
||||
// what, when, via which principal. SecurityEvents carries anomalies such
|
||||
// as a caller under-declaring sensitivity relative to the target floor.
|
||||
type AuditEntry struct {
|
||||
Timestamp time.Time
|
||||
Principal string
|
||||
Actor string
|
||||
Harness string
|
||||
SessionRef string
|
||||
EffectiveClassification string
|
||||
Items []string
|
||||
SecurityEvents []string
|
||||
}
|
||||
|
||||
// AuditSink records the audit entry. The classification-aware
|
||||
// degradation/refusal policy (confidential fails closed, internal
|
||||
// degrades) is the caller's concern in #54; this port just records.
|
||||
type AuditSink interface {
|
||||
Record(ctx context.Context, e AuditEntry) error
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
)
|
||||
|
||||
// Service is the CaptureSession use-case. It depends only on ports.
|
||||
type Service struct {
|
||||
brain BrainStore
|
||||
issues IssueTracker
|
||||
summaries SummaryWriter
|
||||
policy ClassificationPolicy
|
||||
audit AuditSink
|
||||
|
||||
// now is the clock, injectable for deterministic summary paths and
|
||||
// audit timestamps in tests.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService constructs a Service from its ports. summaries may be nil
|
||||
// when no summary persistence is wired; a CaptureInput with a Summary
|
||||
// then fails that item rather than panicking.
|
||||
func NewService(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
|
||||
return &Service{brain: b, issues: tr, summaries: sw, policy: p, audit: a, now: time.Now}
|
||||
}
|
||||
|
||||
var validActions = map[string]bool{"create": true, "close": true, "comment": true}
|
||||
|
||||
// Capture runs the use-case: validate (fail-closed), resolve effective
|
||||
// classification (stricter of declared vs target-derived), then persist
|
||||
// insights → tickets → summary best-effort, emit an audit record, and
|
||||
// return a partial-aware receipt.
|
||||
//
|
||||
// A validation failure returns a non-nil error with nothing written. A
|
||||
// per-item execution failure is recorded in the receipt (no rollback);
|
||||
// the call still returns a nil error so the caller gets the partial
|
||||
// receipt. The I1 origin gate and audit-down degradation are layered on
|
||||
// by #53/#54 around this core.
|
||||
func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt, error) {
|
||||
if err := s.validate(in); err != nil {
|
||||
return CaptureReceipt{}, err
|
||||
}
|
||||
|
||||
declared := classification.Public // unspecified ⇒ lowest ⇒ target floor governs
|
||||
if in.Context.Classification != "" {
|
||||
// Already validated parseable.
|
||||
declared, _ = classification.ParseLevel(in.Context.Classification)
|
||||
}
|
||||
|
||||
effective, securityEvents := s.resolveClassification(declared, in)
|
||||
|
||||
receipt := CaptureReceipt{
|
||||
Errors: []ItemError{},
|
||||
EffectiveClassification: effective.String(),
|
||||
DryRun: in.DryRun,
|
||||
}
|
||||
|
||||
if in.DryRun {
|
||||
// Would-be receipt: mark planned items ok, write nothing (not even
|
||||
// audit — dry_run touches nothing).
|
||||
for range in.Insights {
|
||||
receipt.Insights = append(receipt.Insights, InsightResult{OK: true})
|
||||
}
|
||||
for _, tk := range in.Tickets {
|
||||
receipt.Tickets = append(receipt.Tickets, TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number, OK: true})
|
||||
}
|
||||
if in.Summary != nil {
|
||||
receipt.Summary = &SummaryResult{Path: s.summaryPath(in.Context, in.Summary), OK: true}
|
||||
}
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
var landed []string
|
||||
|
||||
for i, ins := range in.Insights {
|
||||
res, item, err := s.persistInsight(ctx, ins)
|
||||
receipt.Insights = append(receipt.Insights, res)
|
||||
if err != nil {
|
||||
receipt.Errors = append(receipt.Errors, ItemError{Item: fmt.Sprintf("insight[%d]", i), Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
landed = append(landed, item)
|
||||
}
|
||||
|
||||
for i, tk := range in.Tickets {
|
||||
res, err := s.persistTicket(ctx, tk)
|
||||
receipt.Tickets = append(receipt.Tickets, res)
|
||||
if err != nil {
|
||||
receipt.Errors = append(receipt.Errors, ItemError{Item: fmt.Sprintf("ticket[%d]", i), Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
landed = append(landed, fmt.Sprintf("ticket:%s#%d", tk.Repo, res.Number))
|
||||
}
|
||||
|
||||
if in.Summary != nil {
|
||||
res, err := s.persistSummary(ctx, in.Context, in.Summary)
|
||||
receipt.Summary = &res
|
||||
if err != nil {
|
||||
receipt.Errors = append(receipt.Errors, ItemError{Item: "summary", Error: err.Error()})
|
||||
} else {
|
||||
landed = append(landed, "summary:"+res.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// I5: emit a request-level audit record of exactly what landed.
|
||||
// Best-effort here; the classification-aware refusal/degradation
|
||||
// policy is #54.
|
||||
if err := s.audit.Record(ctx, AuditEntry{
|
||||
Timestamp: s.now().UTC(),
|
||||
Principal: in.Context.Principal,
|
||||
Actor: in.Context.Actor,
|
||||
Harness: in.Context.Harness,
|
||||
SessionRef: in.Context.SessionRef,
|
||||
EffectiveClassification: effective.String(),
|
||||
Items: landed,
|
||||
SecurityEvents: securityEvents,
|
||||
}); err != nil {
|
||||
receipt.Errors = append(receipt.Errors, ItemError{Item: "audit", Error: err.Error()})
|
||||
}
|
||||
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
// validate enforces fail-closed structural validity over the whole
|
||||
// request before any write. A bad declared classification, an invalid
|
||||
// wing/hall, an empty insight, or a malformed ticket aborts the capture
|
||||
// with nothing written.
|
||||
func (s *Service) validate(in CaptureInput) error {
|
||||
if in.Context.Classification != "" {
|
||||
if _, err := classification.ParseLevel(in.Context.Classification); err != nil {
|
||||
return fmt.Errorf("context.classification: %w", err)
|
||||
}
|
||||
}
|
||||
for i, ins := range in.Insights {
|
||||
if strings.TrimSpace(ins.Text) == "" {
|
||||
return fmt.Errorf("insight[%d]: text is required", i)
|
||||
}
|
||||
if strings.TrimSpace(ins.Wing) == "" {
|
||||
return fmt.Errorf("insight[%d]: wing is required", i)
|
||||
}
|
||||
if !brain.IsValidHall(ins.Hall) {
|
||||
return fmt.Errorf("insight[%d]: invalid hall %q", i, ins.Hall)
|
||||
}
|
||||
}
|
||||
for i, tk := range in.Tickets {
|
||||
if strings.TrimSpace(tk.Repo) == "" {
|
||||
return fmt.Errorf("ticket[%d]: repo is required", i)
|
||||
}
|
||||
if !validActions[tk.Action] {
|
||||
return fmt.Errorf("ticket[%d]: invalid action %q (want create/close/comment)", i, tk.Action)
|
||||
}
|
||||
if tk.Action == "create" && strings.TrimSpace(tk.Title) == "" {
|
||||
return fmt.Errorf("ticket[%d]: create requires a title", i)
|
||||
}
|
||||
if (tk.Action == "close" || tk.Action == "comment") && tk.Number <= 0 {
|
||||
return fmt.Errorf("ticket[%d]: %s requires an issue number", i, tk.Action)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveClassification computes the effective level (stricter of
|
||||
// declared and every target's derived level) and collects a security
|
||||
// event whenever the caller under-declared relative to a target floor.
|
||||
func (s *Service) resolveClassification(declared classification.Level, in CaptureInput) (classification.Level, []string) {
|
||||
effective := declared
|
||||
var events []string
|
||||
consider := func(kind classification.TargetKind, name string) {
|
||||
derived := s.policy.Derive(classification.Target{Kind: kind, Name: name})
|
||||
effective = classification.Stricter(effective, derived)
|
||||
if declared < derived {
|
||||
events = append(events, fmt.Sprintf("classification under-declared: declared=%s target=%s(%s) derived=%s",
|
||||
declared, name, kindString(kind), derived))
|
||||
}
|
||||
}
|
||||
for _, ins := range in.Insights {
|
||||
consider(classification.WingTarget, ins.Wing)
|
||||
}
|
||||
for _, tk := range in.Tickets {
|
||||
consider(classification.RepoTarget, tk.Repo)
|
||||
}
|
||||
if in.Summary != nil {
|
||||
for _, repo := range in.Summary.ReposTouched {
|
||||
consider(classification.RepoTarget, repo)
|
||||
}
|
||||
}
|
||||
return effective, events
|
||||
}
|
||||
|
||||
func (s *Service) persistInsight(ctx context.Context, ins Insight) (InsightResult, string, error) {
|
||||
note := Note{Content: ins.Text, Wing: ins.Wing, Hall: ins.Hall, Filename: brain.Sanitise(firstLine(ins.Text))}
|
||||
var ref Ref
|
||||
var err error
|
||||
if ins.SupersedeSlug != "" {
|
||||
note.Reason = "superseded via capture"
|
||||
ref, err = s.brain.Update(ctx, ins.SupersedeSlug, note)
|
||||
} else {
|
||||
ref, err = s.brain.Write(ctx, note)
|
||||
}
|
||||
if err != nil {
|
||||
return InsightResult{OK: false, Superseded: ins.SupersedeSlug != ""}, "", err
|
||||
}
|
||||
return InsightResult{
|
||||
ID: ref.ID, Path: ref.Path, ContentHash: ref.ContentHash,
|
||||
Superseded: ref.Superseded, OK: true,
|
||||
}, "insight:" + ref.ID, nil
|
||||
}
|
||||
|
||||
func (s *Service) persistTicket(ctx context.Context, tk Ticket) (TicketResult, error) {
|
||||
res := TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number}
|
||||
var ref IssueRef
|
||||
var err error
|
||||
switch tk.Action {
|
||||
case "create":
|
||||
ref, err = s.issues.CreateIssue(ctx, tk.Repo, tk.Title, tk.Body)
|
||||
case "close":
|
||||
ref, err = s.issues.CloseIssue(ctx, tk.Repo, tk.Number)
|
||||
case "comment":
|
||||
ref, err = s.issues.CommentIssue(ctx, tk.Repo, tk.Number, tk.Body)
|
||||
}
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if ref.Number != 0 {
|
||||
res.Number = ref.Number
|
||||
}
|
||||
res.URL = ref.URL
|
||||
res.OK = true
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Service) persistSummary(ctx context.Context, c CaptureContext, sum *Summary) (SummaryResult, error) {
|
||||
if s.summaries == nil {
|
||||
return SummaryResult{OK: false}, fmt.Errorf("no summary writer configured")
|
||||
}
|
||||
path := s.summaryPath(c, sum)
|
||||
content := s.renderSummary(c, sum)
|
||||
repo := "ai-sessions"
|
||||
if err := s.summaries.WriteFile(ctx, repo, path, content); err != nil {
|
||||
return SummaryResult{Path: path, OK: false}, err
|
||||
}
|
||||
return SummaryResult{Path: path, OK: true}, nil
|
||||
}
|
||||
|
||||
// summaryPath builds summaries/<harness>/<YYYY-MM>/<date>-<slug>-<ref8>.md.
|
||||
// The ref8 disambiguator is derived from the session_ref (or the title
|
||||
// when no ref is present) so distinct sessions never collide.
|
||||
func (s *Service) summaryPath(c CaptureContext, sum *Summary) string {
|
||||
t := s.now().UTC()
|
||||
slug := brain.Sanitise(sum.Title)
|
||||
if slug == "" {
|
||||
slug = "summary"
|
||||
}
|
||||
seed := c.SessionRef
|
||||
if seed == "" {
|
||||
seed = sum.Title + sum.Body
|
||||
}
|
||||
sum8 := shortHash(seed)
|
||||
return fmt.Sprintf("summaries/%s/%s/%s-%s-%s.md",
|
||||
brain.Sanitise(c.Harness), t.Format("2006-01"), t.Format("2006-01-02"), slug, sum8)
|
||||
}
|
||||
|
||||
// renderSummary stamps fidelity + session metadata into frontmatter so the
|
||||
// richer-fidelity-supersedes-thinner collision rule has the data it needs.
|
||||
func (s *Service) renderSummary(c CaptureContext, sum *Summary) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("---\n")
|
||||
fmt.Fprintf(&b, "title: %s\n", sum.Title)
|
||||
fmt.Fprintf(&b, "harness: %s\n", c.Harness)
|
||||
if c.SessionRef != "" {
|
||||
fmt.Fprintf(&b, "session_ref: %s\n", c.SessionRef)
|
||||
}
|
||||
fmt.Fprintf(&b, "fidelity: %s\n", c.Fidelity)
|
||||
fmt.Fprintf(&b, "captured_at: %s\n", s.now().UTC().Format(time.RFC3339))
|
||||
if len(sum.ReposTouched) > 0 {
|
||||
fmt.Fprintf(&b, "repos_touched: [%s]\n", strings.Join(sum.ReposTouched, ", "))
|
||||
}
|
||||
b.WriteString("---\n\n")
|
||||
b.WriteString(sum.Body)
|
||||
if !strings.HasSuffix(sum.Body, "\n") {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func kindString(k classification.TargetKind) string {
|
||||
if k == classification.RepoTarget {
|
||||
return "repo"
|
||||
}
|
||||
return "wing"
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
s = strings.TrimLeft(s, "# ")
|
||||
if len(s) > 60 {
|
||||
s = s[:60]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func shortHash(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])[:8]
|
||||
}
|
||||
@@ -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