package capture import ( "context" "errors" "fmt" "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 // failOnTitle makes CreateIssue fail for one specific title, so a // partial-failure receipt can be exercised without failing every item. failOnTitle string } func (f *fakeTracker) CreateIssue(_ context.Context, repo, title, _ string) (IssueRef, error) { if f.err != nil { return IssueRef{}, f.err } if f.failOnTitle != "" && title == f.failOnTitle { return IssueRef{}, errors.New("tracker refused this one") } 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, _ string) (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 } // 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 // Record error reserveErr error // Reserve error (refuse before write) reserveMode AuditOutcome } func (f *fakeAudit) Reserve(_ context.Context, _ classification.Level) (AuditOutcome, error) { if f.reserveErr != nil { return 0, f.reserveErr } return f.reserveMode, nil } func (f *fakeAudit) Record(_ context.Context, e AuditEntry, _ AuditOutcome) error { if f.err != nil { return f.err } f.entries = append(f.entries, e) return nil } // --- helpers --- func newSvc(b BrainStore, tr IssueTracker, p ClassificationPolicy, a AuditSink) *Service { s := NewService(b, tr, 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, 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{}, 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, 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{}, 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, 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, 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{}, 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{}, 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") } // --- I1 sovereignty gate (#53) --- func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) { b := &fakeBrain{} tr := &fakeTracker{} au := &fakeAudit{} pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}} svc := newSvc(b, tr, pol, au) ctx := baseCtx() ctx.Classification = "confidential" ctx.Origin = ZoneUSNexus _, err := svc.Capture(context.Background(), CaptureInput{ Context: ctx, Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}}, }) require.Error(t, err) assert.ErrorIs(t, err, ErrSovereigntyRefused) // Refused before any write. assert.Empty(t, b.writes) assert.Empty(t, tr.created) // Refusal is audited. require.Len(t, au.entries, 1) assert.Empty(t, au.entries[0].Items, "no items landed on refusal") } func TestCaptureAllowsConfidentialViaSovereign(t *testing.T) { b := &fakeBrain{} pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}} svc := newSvc(b, &fakeTracker{}, pol, &fakeAudit{}) ctx := baseCtx() ctx.Classification = "confidential" ctx.Origin = ZoneSovereign rec, err := svc.Capture(context.Background(), CaptureInput{ Context: ctx, Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}}, }) require.NoError(t, err) assert.True(t, rec.Insights[0].OK) assert.Len(t, b.writes, 1) } func TestCaptureAssertedLabelIgnoredAndLogged(t *testing.T) { // Caller asserts harness "sovereign-soil" but principal resolves to // us-nexus; confidential ⇒ refused, and the discrepancy is a security event. au := &fakeAudit{} pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}} svc := newSvc(&fakeBrain{}, &fakeTracker{}, pol, au) ctx := baseCtx() ctx.Harness = "sovereign-soil" // asserted ctx.Origin = ZoneUSNexus // server-derived ctx.Classification = "confidential" _, err := svc.Capture(context.Background(), CaptureInput{ Context: ctx, Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}}, }) require.ErrorIs(t, err, ErrSovereigntyRefused) require.Len(t, au.entries, 1) joined := strings.Join(au.entries[0].SecurityEvents, " | ") assert.Contains(t, joined, "asserted-vs-derived origin mismatch") assert.Contains(t, joined, "I1 refusal") } func TestCaptureInternalViaUSNexusAllowed(t *testing.T) { // us-nexus origin is fine for non-confidential data. b := &fakeBrain{} svc := newSvc(b, &fakeTracker{}, fakePolicy{}, &fakeAudit{}) ctx := baseCtx() ctx.Origin = ZoneUSNexus // internal classification, so gate doesn't fire rec, err := svc.Capture(context.Background(), CaptureInput{ Context: ctx, Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}}, }) require.NoError(t, err) assert.True(t, rec.Insights[0].OK) } // --- I5 audit gate (#54) --- func TestCaptureRefusesWhenAuditReserveFails(t *testing.T) { // Reserve refusing (e.g. confidential + central sink down, or the floor) // aborts the capture before any write. b := &fakeBrain{} tr := &fakeTracker{} au := &fakeAudit{reserveErr: errors.New("central sink unreachable")} svc := newSvc(b, tr, fakePolicy{}, au) _, err := svc.Capture(context.Background(), CaptureInput{ Context: baseCtx(), Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}}, }) require.Error(t, err) assert.ErrorIs(t, err, ErrAuditUnavailable) assert.Empty(t, b.writes, "nothing written when audit unavailable") assert.Empty(t, tr.created) } func TestCaptureFlagsLocallyBufferedAudit(t *testing.T) { // Reserve returns AuditBuffered (internal/public, central down) → capture // proceeds and the receipt flags the degraded audit state. b := &fakeBrain{} au := &fakeAudit{reserveMode: AuditBuffered} svc := newSvc(b, &fakeTracker{}, fakePolicy{}, au) rec, err := svc.Capture(context.Background(), CaptureInput{ Context: baseCtx(), Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}}, }) require.NoError(t, err) assert.True(t, rec.Insights[0].OK, "capture proceeds on degraded audit") assert.True(t, rec.AuditBuffered, "receipt flags locally-buffered audit") require.Len(t, au.entries, 1) } func TestCaptureDryRunSkipsAuditGate(t *testing.T) { // dry_run must not even probe the audit sink (writes nothing anywhere). au := &fakeAudit{reserveErr: errors.New("would refuse")} svc := newSvc(&fakeBrain{}, &fakeTracker{}, fakePolicy{}, au) rec, err := svc.Capture(context.Background(), CaptureInput{ Context: baseCtx(), DryRun: true, Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}}, }) require.NoError(t, err, "dry-run does not hit the audit gate") assert.True(t, rec.DryRun) assert.Empty(t, au.entries) } // ---- hyperguild#91 part 1 ------------------------------------------------- // // The reported symptom was "capture with a tickets array fails" (infra#265: // 20 tickets errored, 5 errored, caller fell back to 20 individual // issue_create calls). The array was never the failing dimension — every // ticket targeted a client repo, and the I1 gate refuses that through a // us-nexus origin at ANY length. The refusal did not say which target // escalated the classification, so it read as a malfunction. func TestCapture_TicketsArrayWorksAtLengthOne(t *testing.T) { b, tr, au := &fakeBrain{}, &fakeTracker{}, &fakeAudit{} svc := newSvc(b, tr, fakePolicy{}, au) rec, err := svc.Capture(context.Background(), CaptureInput{ Context: baseCtx(), Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "one", Body: "b"}}, }) require.NoError(t, err) require.Len(t, rec.Tickets, 1) assert.True(t, rec.Tickets[0].OK) assert.Len(t, tr.created, 1) assert.Empty(t, rec.Errors) } func TestCapture_TicketsArrayWorksAtLengthMany(t *testing.T) { b, tr, au := &fakeBrain{}, &fakeTracker{}, &fakeAudit{} svc := newSvc(b, tr, fakePolicy{}, au) in := CaptureInput{Context: baseCtx()} for i := range 20 { in.Tickets = append(in.Tickets, Ticket{ Repo: "hyperguild", Action: "create", Title: fmt.Sprintf("t%02d", i), Body: "b", }) } rec, err := svc.Capture(context.Background(), in) require.NoError(t, err) require.Len(t, rec.Tickets, 20, "one receipt entry per ticket") for i, r := range rec.Tickets { assert.True(t, r.OK, "ticket %d failed: %+v", i, rec.Errors) } assert.Len(t, tr.created, 20) assert.Empty(t, rec.Errors) } func TestCapture_OneFailingTicketDoesNotSinkTheRest(t *testing.T) { // infra#265 asked explicitly: "if ticket 3 of 5 fails, what happened to // 1 and 2?" They land, and the receipt says so per item. b, au := &fakeBrain{}, &fakeAudit{} tr := &fakeTracker{failOnTitle: "boom"} svc := newSvc(b, tr, fakePolicy{}, au) rec, err := svc.Capture(context.Background(), CaptureInput{ Context: baseCtx(), Tickets: []Ticket{ {Repo: "hyperguild", Action: "create", Title: "ok-1", Body: "b"}, {Repo: "hyperguild", Action: "create", Title: "boom", Body: "b"}, {Repo: "hyperguild", Action: "create", Title: "ok-3", Body: "b"}, }, }) require.NoError(t, err, "a per-item failure is a partial receipt, not a call error") require.Len(t, rec.Tickets, 3) assert.True(t, rec.Tickets[0].OK) assert.False(t, rec.Tickets[1].OK) assert.True(t, rec.Tickets[2].OK) require.Len(t, rec.Errors, 1) assert.Contains(t, rec.Errors[0].Item, "ticket[1]") } func TestCapture_I1RefusalNamesWhatEscalatedTheClassification(t *testing.T) { // The actual defect behind infra#265. Without the offending target in // the message, a caller with 20 tickets cannot tell which repo tripped // the gate — and reasonably concludes the array itself is broken. b, tr, au := &fakeBrain{}, &fakeTracker{}, &fakeAudit{} pol := fakePolicy{tags: map[string]classification.Level{"trucker-ecommerce": classification.Confidential}} svc := newSvc(b, tr, pol, au) ctx := baseCtx() ctx.Origin = ZoneUSNexus _, err := svc.Capture(context.Background(), CaptureInput{ Context: ctx, Tickets: []Ticket{ {Repo: "hyperguild", Action: "create", Title: "fine", Body: "b"}, {Repo: "trucker-ecommerce", Action: "create", Title: "client work", Body: "b"}, }, }) require.ErrorIs(t, err, ErrSovereigntyRefused) assert.Contains(t, err.Error(), "trucker-ecommerce", "the refusal must name the target that escalated the classification") assert.NotContains(t, err.Error(), "hyperguild", "only the offending target, or the message is noise at 20 tickets") assert.Empty(t, tr.created, "refused before any write") }