feat(capture): remove summary->ai-sessions write path
CI / Lint / Test / Vet (push) Successful in 29s
CI / Mirror to GitHub (push) Successful in 4s

ai-sessions#13: capture's `summary` field wrote a frontmatter shape
(title/harness/fidelity/captured_at/repos_touched) that ai-sessions'
own extract/audit pipeline can't parse -- silently invisible to that
repo's own audit tooling, and bypassing its redaction + Stage2
completeness gates by construction. Only 5 files ever landed this way
over 3 weeks; the summary capability's whole value (speed) fights
ai-sessions' whole value (redacted, audited, complete), so kill it
rather than build a second parse branch.

capture now persists insights -> brain and action items -> Gitea
tickets only. Removes Summary/SummaryResult/SummaryWriter and all
wiring (service, REST body, MCP tool schema); close-session updated to
stop assembling a summary payload. specs/capture-*.md kept as historical
record with a superseded note -- the feature shipped and is documented,
just no longer current behaviour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPdSHbp9Utb2hPFm9wDG59
This commit is contained in:
2026-07-27 23:17:56 +02:00
co-authored by Claude Sonnet 5
parent 9dcd60931a
commit 1cea2c9f78
12 changed files with 50 additions and 201 deletions
+1 -5
View File
@@ -457,12 +457,8 @@ func main() {
os.Exit(1) os.Exit(1)
} }
auditSink := buildAuditSink(ctx, brainDir, logger) auditSink := buildAuditSink(ctx, brainDir, logger)
// The Gitea client also satisfies SummaryWriter (#66): session
// summaries are written to mathias/ai-sessions over the same API
// token. nil only if a future tracker impl lacks file writes.
summaryWriter, _ := tracker.(capture.SummaryWriter)
captureSvc := capture.NewService( captureSvc := capture.NewService(
mcpSrv.BrainStore(), tracker, summaryWriter, classCfg, auditSink) mcpSrv.BrainStore(), tracker, classCfg, auditSink)
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS")) sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
resolver := capturehttp.NewOriginResolver(sovereign) resolver := capturehttp.NewOriginResolver(sovereign)
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver) captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
+2 -18
View File
@@ -1,8 +1,7 @@
// Package capture is the Clean-Architecture use-case for the uniform // Package capture is the Clean-Architecture use-case for the uniform
// capture capability (issue #49/#51): persist a finished session's // capture capability (issue #49/#51): persist a finished session's
// valuable output — insights → brain, action items → Gitea tickets, // valuable output — insights → brain, action items → Gitea tickets
// optional summary → ai-sessions — with one invocation, identical core // with one invocation, identical core behaviour across every harness.
// behaviour across every harness.
// //
// This package is pure orchestration. It depends only on ports // This package is pure orchestration. It depends only on ports
// (interfaces) and plain entities — no HTTP, no live Gitea, no embedding // (interfaces) and plain entities — no HTTP, no live Gitea, no embedding
@@ -83,19 +82,11 @@ type Ticket struct {
Body string 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. // CaptureInput is the whole capture request.
type CaptureInput struct { type CaptureInput struct {
Context CaptureContext Context CaptureContext
Insights []Insight Insights []Insight
Tickets []Ticket Tickets []Ticket
Summary *Summary
DryRun bool DryRun bool
} }
@@ -117,12 +108,6 @@ type TicketResult struct {
OK bool `json:"ok"` 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 // ItemError pins a failure to a specific request item for the partial
// receipt. Item is a stable locator like "insight[1]" or "ticket[0]". // receipt. Item is a stable locator like "insight[1]" or "ticket[0]".
type ItemError struct { type ItemError struct {
@@ -136,7 +121,6 @@ type ItemError struct {
type CaptureReceipt struct { type CaptureReceipt struct {
Insights []InsightResult `json:"insights"` Insights []InsightResult `json:"insights"`
Tickets []TicketResult `json:"tickets"` Tickets []TicketResult `json:"tickets"`
Summary *SummaryResult `json:"summary,omitempty"`
Errors []ItemError `json:"errors"` Errors []ItemError `json:"errors"`
EffectiveClassification string `json:"effective_classification,omitempty"` EffectiveClassification string `json:"effective_classification,omitempty"`
DryRun bool `json:"dry_run"` DryRun bool `json:"dry_run"`
-5
View File
@@ -69,11 +69,6 @@ type IssueTracker interface {
CommentIssue(ctx context.Context, repo string, number int, body string) (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 // ClassificationPolicy derives a target's sensitivity (model C). The
// "stricter wins" combination of declared vs derived is use-case policy // "stricter wins" combination of declared vs derived is use-case policy
// and lives in the service, so the port stays minimal. Satisfied by // and lives in the service, so the port stays minimal. Satisfied by
+9 -91
View File
@@ -2,8 +2,6 @@ package capture
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -14,22 +12,19 @@ import (
// Service is the CaptureSession use-case. It depends only on ports. // Service is the CaptureSession use-case. It depends only on ports.
type Service struct { type Service struct {
brain BrainStore brain BrainStore
issues IssueTracker issues IssueTracker
summaries SummaryWriter policy ClassificationPolicy
policy ClassificationPolicy audit AuditSink
audit AuditSink
// now is the clock, injectable for deterministic summary paths and // now is the clock, injectable for deterministic audit timestamps in
// audit timestamps in tests. // tests.
now func() time.Time now func() time.Time
} }
// NewService constructs a Service from its ports. summaries may be nil // NewService constructs a Service from its ports.
// when no summary persistence is wired; a CaptureInput with a Summary func NewService(b BrainStore, tr IssueTracker, p ClassificationPolicy, a AuditSink) *Service {
// then fails that item rather than panicking. return &Service{brain: b, issues: tr, policy: p, audit: a, now: time.Now}
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} var validActions = map[string]bool{"create": true, "close": true, "comment": true}
@@ -131,9 +126,6 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
for _, tk := range in.Tickets { for _, tk := range in.Tickets {
receipt.Tickets = append(receipt.Tickets, TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number, OK: true}) 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 return receipt, nil
} }
@@ -169,16 +161,6 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
landed = append(landed, fmt.Sprintf("ticket:%s#%d", tk.Repo, res.Number)) 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: persist the request-level audit record of exactly what landed, // I5: persist the request-level audit record of exactly what landed,
// using the outcome reserved before the writes. AuditBuffered surfaces // using the outcome reserved before the writes. AuditBuffered surfaces
// the degraded (locally-buffered) state on the receipt. // the degraded (locally-buffered) state on the receipt.
@@ -259,11 +241,6 @@ func (s *Service) resolveClassification(declared classification.Level, in Captur
for _, tk := range in.Tickets { for _, tk := range in.Tickets {
consider(classification.RepoTarget, tk.Repo) consider(classification.RepoTarget, tk.Repo)
} }
if in.Summary != nil {
for _, repo := range in.Summary.ReposTouched {
consider(classification.RepoTarget, repo)
}
}
return effective, events return effective, events
} }
@@ -309,60 +286,6 @@ func (s *Service) persistTicket(ctx context.Context, tk Ticket) (TicketResult, e
return res, nil 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 { func kindString(k classification.TargetKind) string {
if k == classification.RepoTarget { if k == classification.RepoTarget {
return "repo" return "repo"
@@ -381,8 +304,3 @@ func firstLine(s string) string {
} }
return s return s
} }
func shortHash(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])[:8]
}
+17 -52
View File
@@ -85,21 +85,6 @@ func (f *fakeTracker) CommentIssue(_ context.Context, repo string, number int, _
return IssueRef{Repo: repo, Number: number}, nil 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 // fakePolicy derives from an explicit map; default Internal so tests pin
// behaviour without depending on the real defaulting. // behaviour without depending on the real defaulting.
type fakePolicy struct{ tags map[string]classification.Level } type fakePolicy struct{ tags map[string]classification.Level }
@@ -135,8 +120,8 @@ func (f *fakeAudit) Record(_ context.Context, e AuditEntry, _ AuditOutcome) erro
// --- helpers --- // --- helpers ---
func newSvc(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service { func newSvc(b BrainStore, tr IssueTracker, p ClassificationPolicy, a AuditSink) *Service {
s := NewService(b, tr, sw, p, a) s := NewService(b, tr, p, a)
s.now = func() time.Time { return time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC) } s.now = func() time.Time { return time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC) }
return s return s
} }
@@ -151,7 +136,7 @@ func TestCaptureHappyPath(t *testing.T) {
b := &fakeBrain{} b := &fakeBrain{}
tr := &fakeTracker{} tr := &fakeTracker{}
au := &fakeAudit{} au := &fakeAudit{}
svc := newSvc(b, tr, nil, fakePolicy{}, au) svc := newSvc(b, tr, fakePolicy{}, au)
rec, err := svc.Capture(context.Background(), CaptureInput{ rec, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(), Context: baseCtx(),
@@ -180,7 +165,7 @@ func TestCaptureHappyPath(t *testing.T) {
func TestCaptureSupersedeNotDuplicate(t *testing.T) { func TestCaptureSupersedeNotDuplicate(t *testing.T) {
b := &fakeBrain{} b := &fakeBrain{}
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{}) svc := newSvc(b, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
rec, err := svc.Capture(context.Background(), CaptureInput{ rec, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(), Context: baseCtx(),
@@ -197,7 +182,7 @@ func TestCaptureValidationFailClosed(t *testing.T) {
b := &fakeBrain{} b := &fakeBrain{}
tr := &fakeTracker{} tr := &fakeTracker{}
au := &fakeAudit{} au := &fakeAudit{}
svc := newSvc(b, tr, nil, fakePolicy{}, au) svc := newSvc(b, tr, fakePolicy{}, au)
_, err := svc.Capture(context.Background(), CaptureInput{ _, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(), Context: baseCtx(),
@@ -216,7 +201,7 @@ func TestCaptureValidationFailClosed(t *testing.T) {
} }
func TestCaptureValidationRejectsBadTicket(t *testing.T) { func TestCaptureValidationRejectsBadTicket(t *testing.T) {
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{}) svc := newSvc(&fakeBrain{}, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
_, err := svc.Capture(context.Background(), CaptureInput{ _, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(), Context: baseCtx(),
Tickets: []Ticket{{Repo: "hyperguild", Action: "frobnicate"}}, // bad action Tickets: []Ticket{{Repo: "hyperguild", Action: "frobnicate"}}, // bad action
@@ -239,7 +224,7 @@ func TestCapturePartialFailureBestEffort(t *testing.T) {
}} }}
tr := &fakeTracker{} tr := &fakeTracker{}
au := &fakeAudit{} au := &fakeAudit{}
svc := newSvc(b, tr, nil, fakePolicy{}, au) svc := newSvc(b, tr, fakePolicy{}, au)
rec, err := svc.Capture(context.Background(), CaptureInput{ rec, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(), Context: baseCtx(),
@@ -264,7 +249,7 @@ func TestCaptureDryRunWritesNothing(t *testing.T) {
b := &fakeBrain{} b := &fakeBrain{}
tr := &fakeTracker{} tr := &fakeTracker{}
au := &fakeAudit{} au := &fakeAudit{}
svc := newSvc(b, tr, nil, fakePolicy{}, au) svc := newSvc(b, tr, fakePolicy{}, au)
rec, err := svc.Capture(context.Background(), CaptureInput{ rec, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(), Context: baseCtx(),
@@ -287,7 +272,7 @@ func TestCaptureStricterClassificationWins(t *testing.T) {
b := &fakeBrain{} b := &fakeBrain{}
au := &fakeAudit{} au := &fakeAudit{}
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}} pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
svc := newSvc(b, &fakeTracker{}, nil, pol, au) svc := newSvc(b, &fakeTracker{}, pol, au)
ctx := baseCtx() ctx := baseCtx()
ctx.Classification = "internal" ctx.Classification = "internal"
@@ -306,7 +291,7 @@ func TestCaptureCallerRaisingSensitivityHonoured(t *testing.T) {
// Caller declares confidential; target internal → effective confidential, NOT a security event. // Caller declares confidential; target internal → effective confidential, NOT a security event.
au := &fakeAudit{} au := &fakeAudit{}
pol := fakePolicy{tags: map[string]classification.Level{"hyperguild": classification.Internal}} pol := fakePolicy{tags: map[string]classification.Level{"hyperguild": classification.Internal}}
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au) svc := newSvc(&fakeBrain{}, &fakeTracker{}, pol, au)
ctx := baseCtx() ctx := baseCtx()
ctx.Classification = "confidential" ctx.Classification = "confidential"
@@ -319,26 +304,6 @@ func TestCaptureCallerRaisingSensitivityHonoured(t *testing.T) {
assert.Empty(t, au.entries[0].SecurityEvents, "raising sensitivity is honoured, not flagged") 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")
}
// --- I1 sovereignty gate (#53) --- // --- I1 sovereignty gate (#53) ---
func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) { func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
@@ -346,7 +311,7 @@ func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
tr := &fakeTracker{} tr := &fakeTracker{}
au := &fakeAudit{} au := &fakeAudit{}
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}} pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
svc := newSvc(b, tr, nil, pol, au) svc := newSvc(b, tr, pol, au)
ctx := baseCtx() ctx := baseCtx()
ctx.Classification = "confidential" ctx.Classification = "confidential"
@@ -368,7 +333,7 @@ func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
func TestCaptureAllowsConfidentialViaSovereign(t *testing.T) { func TestCaptureAllowsConfidentialViaSovereign(t *testing.T) {
b := &fakeBrain{} b := &fakeBrain{}
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}} pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
svc := newSvc(b, &fakeTracker{}, nil, pol, &fakeAudit{}) svc := newSvc(b, &fakeTracker{}, pol, &fakeAudit{})
ctx := baseCtx() ctx := baseCtx()
ctx.Classification = "confidential" ctx.Classification = "confidential"
@@ -387,7 +352,7 @@ func TestCaptureAssertedLabelIgnoredAndLogged(t *testing.T) {
// us-nexus; confidential ⇒ refused, and the discrepancy is a security event. // us-nexus; confidential ⇒ refused, and the discrepancy is a security event.
au := &fakeAudit{} au := &fakeAudit{}
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}} pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au) svc := newSvc(&fakeBrain{}, &fakeTracker{}, pol, au)
ctx := baseCtx() ctx := baseCtx()
ctx.Harness = "sovereign-soil" // asserted ctx.Harness = "sovereign-soil" // asserted
@@ -407,7 +372,7 @@ func TestCaptureAssertedLabelIgnoredAndLogged(t *testing.T) {
func TestCaptureInternalViaUSNexusAllowed(t *testing.T) { func TestCaptureInternalViaUSNexusAllowed(t *testing.T) {
// us-nexus origin is fine for non-confidential data. // us-nexus origin is fine for non-confidential data.
b := &fakeBrain{} b := &fakeBrain{}
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{}) svc := newSvc(b, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
ctx := baseCtx() ctx := baseCtx()
ctx.Origin = ZoneUSNexus // internal classification, so gate doesn't fire ctx.Origin = ZoneUSNexus // internal classification, so gate doesn't fire
rec, err := svc.Capture(context.Background(), CaptureInput{ rec, err := svc.Capture(context.Background(), CaptureInput{
@@ -426,7 +391,7 @@ func TestCaptureRefusesWhenAuditReserveFails(t *testing.T) {
b := &fakeBrain{} b := &fakeBrain{}
tr := &fakeTracker{} tr := &fakeTracker{}
au := &fakeAudit{reserveErr: errors.New("central sink unreachable")} au := &fakeAudit{reserveErr: errors.New("central sink unreachable")}
svc := newSvc(b, tr, nil, fakePolicy{}, au) svc := newSvc(b, tr, fakePolicy{}, au)
_, err := svc.Capture(context.Background(), CaptureInput{ _, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(), Context: baseCtx(),
@@ -443,7 +408,7 @@ func TestCaptureFlagsLocallyBufferedAudit(t *testing.T) {
// proceeds and the receipt flags the degraded audit state. // proceeds and the receipt flags the degraded audit state.
b := &fakeBrain{} b := &fakeBrain{}
au := &fakeAudit{reserveMode: AuditBuffered} au := &fakeAudit{reserveMode: AuditBuffered}
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, au) svc := newSvc(b, &fakeTracker{}, fakePolicy{}, au)
rec, err := svc.Capture(context.Background(), CaptureInput{ rec, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(), Context: baseCtx(),
@@ -458,7 +423,7 @@ func TestCaptureFlagsLocallyBufferedAudit(t *testing.T) {
func TestCaptureDryRunSkipsAuditGate(t *testing.T) { func TestCaptureDryRunSkipsAuditGate(t *testing.T) {
// dry_run must not even probe the audit sink (writes nothing anywhere). // dry_run must not even probe the audit sink (writes nothing anywhere).
au := &fakeAudit{reserveErr: errors.New("would refuse")} au := &fakeAudit{reserveErr: errors.New("would refuse")}
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, au) svc := newSvc(&fakeBrain{}, &fakeTracker{}, fakePolicy{}, au)
rec, err := svc.Capture(context.Background(), CaptureInput{ rec, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(), Context: baseCtx(),
-15
View File
@@ -55,7 +55,6 @@ type request struct {
Context contextBody `json:"context"` Context contextBody `json:"context"`
Insights []insightBody `json:"insights"` Insights []insightBody `json:"insights"`
Tickets []ticketBody `json:"tickets"` Tickets []ticketBody `json:"tickets"`
Summary *summaryBody `json:"summary,omitempty"`
DryRun bool `json:"dry_run"` DryRun bool `json:"dry_run"`
} }
@@ -82,12 +81,6 @@ type ticketBody struct {
Body string `json:"body,omitempty"` Body string `json:"body,omitempty"`
} }
type summaryBody struct {
Title string `json:"title"`
Body string `json:"body"`
ReposTouched []string `json:"repos_touched,omitempty"`
}
// ServeHTTP authenticates, derives origin, runs the use-case, and maps the // ServeHTTP authenticates, derives origin, runs the use-case, and maps the
// result to an HTTP status. // result to an HTTP status.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -183,11 +176,6 @@ func (b request) toInput() capture.CaptureInput {
Repo: t.Repo, Action: t.Action, Number: t.Number, Title: t.Title, Body: t.Body, Repo: t.Repo, Action: t.Action, Number: t.Number, Title: t.Title, Body: t.Body,
}) })
} }
if b.Summary != nil {
in.Summary = &capture.Summary{
Title: b.Summary.Title, Body: b.Summary.Body, ReposTouched: b.Summary.ReposTouched,
}
}
return in return in
} }
@@ -204,9 +192,6 @@ func statusFor(rec capture.CaptureReceipt) int {
for _, t := range rec.Tickets { for _, t := range rec.Tickets {
count(&ok, &fail, t.OK) count(&ok, &fail, t.OK)
} }
if rec.Summary != nil {
count(&ok, &fail, rec.Summary.OK)
}
switch { switch {
case fail == 0: case fail == 0:
return http.StatusOK return http.StatusOK
@@ -49,7 +49,7 @@ func newHandler(t *testing.T, v capturehttp.Validator, tr capture.IssueTracker,
t.Helper() t.Helper()
cfg, err := classification.Load(t.TempDir()) cfg, err := classification.Load(t.TempDir())
require.NoError(t, err) require.NoError(t, err)
svc := capture.NewService(brainstore.New(t.TempDir()), tr, nil, cfg, audit.NewSlogSink(nil)) svc := capture.NewService(brainstore.New(t.TempDir()), tr, cfg, audit.NewSlogSink(nil))
return capturehttp.New(svc, v, staticTok, "local-cli", capturehttp.NewOriginResolver(sovereign)) return capturehttp.New(svc, v, staticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
} }
@@ -190,7 +190,7 @@ func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOu
func TestAuditUnavailableIs503(t *testing.T) { func TestAuditUnavailableIs503(t *testing.T) {
cfg, err := classification.Load(t.TempDir()) cfg, err := classification.Load(t.TempDir())
require.NoError(t, err) require.NoError(t, err)
svc := capture.NewService(brainstore.New(t.TempDir()), fakeTracker{}, nil, cfg, refusingAudit{}) svc := capture.NewService(brainstore.New(t.TempDir()), fakeTracker{}, cfg, refusingAudit{})
h := capturehttp.New(svc, nil, staticTok, "local-cli", capturehttp.NewOriginResolver(nil)) h := capturehttp.New(svc, nil, staticTok, "local-cli", capturehttp.NewOriginResolver(nil))
rr := do(t, h, "Bearer "+staticTok, internalReq()) rr := do(t, h, "Bearer "+staticTok, internalReq())
+1 -5
View File
@@ -58,17 +58,13 @@ func captureToolDescriptor() map[string]any {
}, },
"insights": map[string]any{"type": "array", "items": insightItem}, "insights": map[string]any{"type": "array", "items": insightItem},
"tickets": map[string]any{"type": "array", "items": ticketItem}, "tickets": map[string]any{"type": "array", "items": ticketItem},
"summary": map[string]any{"type": "object", "properties": map[string]any{
"title": str("summary title"), "body": str("summary body"),
"repos_touched": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
}},
"dry_run": map[string]any{"type": "boolean", "description": "validate + return the would-be receipt, write nothing"}, "dry_run": map[string]any{"type": "boolean", "description": "validate + return the would-be receipt, write nothing"},
}, },
} }
b, _ := json.Marshal(schema) b, _ := json.Marshal(schema)
return map[string]any{ return map[string]any{
"name": "capture", "name": "capture",
"description": "Persist a session's value uniformly: insights → brain (write or supersede), action items → Gitea tickets, optional summary → ai-sessions. The relay door for MCP-native harnesses. Origin is server-derived from your authenticated identity; confidential captures through a us-nexus surface are refused (I1). Returns a partial-aware receipt.", "description": "Persist a session's value uniformly: insights → brain (write or supersede), action items → Gitea tickets. The relay door for MCP-native harnesses. Origin is server-derived from your authenticated identity; confidential captures through a us-nexus surface are refused (I1). Returns a partial-aware receipt.",
"inputSchema": json.RawMessage(b), "inputSchema": json.RawMessage(b),
} }
} }
+1 -1
View File
@@ -49,7 +49,7 @@ func captureServer(t *testing.T, validator capturehttp.Validator, sovereign []st
brainDir := t.TempDir() brainDir := t.TempDir()
cfg, err := classification.Load(brainDir) cfg, err := classification.Load(brainDir)
require.NoError(t, err) require.NoError(t, err)
svc := capture.NewService(brainstore.New(brainDir), capFakeTracker{}, nil, cfg, audit.NewSlogSink(nil)) svc := capture.NewService(brainstore.New(brainDir), capFakeTracker{}, cfg, audit.NewSlogSink(nil))
srv := mcp.NewServer(brainDir, nil, nil, nil) srv := mcp.NewServer(brainDir, nil, nil, nil)
srv.WithCapture(svc, validator, capStaticTok, "local-cli", capturehttp.NewOriginResolver(sovereign)) srv.WithCapture(svc, validator, capStaticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
return srv, brainDir return srv, brainDir
+8 -7
View File
@@ -42,34 +42,35 @@ These actions are **carried into the Phase 4 capture call** as `tickets[]` rathe
## Phase 4 — Capture (one uniform call) ## Phase 4 — Capture (one uniform call)
Persist the session via a **single `capture` call** (the `brain:capture` MCP tool, live on the Claude.ai connector). Capture owns the writes server-side — insights → brain, action items → Gitea tickets, summary → ai-sessions — plus the I1 sovereignty gate, the I5 audit record, and the supersession/read-after-write discipline. The skill's job is to *assemble the payload*, not to write each store itself. Do NOT fall back to separate `gitea:file_write_branch` + `brain_write` steps unless `capture` is unreachable (see fallback below). Persist the session via a **single `capture` call** (the `brain:capture` MCP tool, live on the Claude.ai connector). Capture owns the writes server-side — insights → brain, action items → Gitea tickets — plus the I1 sovereignty gate, the I5 audit record, and the supersession/read-after-write discipline. The skill's job is to *assemble the payload*, not to write each store itself. Do NOT fall back to separate `gitea:file_write_branch` + `brain_write` steps unless `capture` is unreachable (see fallback below).
**Note:** capture no longer writes a session summary anywhere (the `summary``ai-sessions` write path was removed — it produced a frontmatter shape `ai-sessions`' own pipeline couldn't parse, silently invisible to that repo's own audit tooling; see `ai-sessions#13`). If the session's decisions/artifacts are worth a durable narrative beyond the `insights[]` this skill writes to the brain, that's a separate, explicit call — not something this skill does implicitly.
**Assemble one payload:** **Assemble one payload:**
- **`insights[]`** — the generalizable learnings from Phase 1 (decisions/failures worth re-reading). Each: `{text, wing, hall}`; add `supersede_slug` to revise a prior note in place instead of creating a duplicate. `hall` ∈ facts/decisions/failures/hypotheses/sources. - **`insights[]`** — the generalizable learnings from Phase 1 (decisions/failures worth re-reading). Each: `{text, wing, hall}`; add `supersede_slug` to revise a prior note in place instead of creating a duplicate. `hall` ∈ facts/decisions/failures/hypotheses/sources.
- **`tickets[]`** — the issue actions from Phase 3: `{repo, action, ...}` where action ∈ create/close/comment. Owner is always `mathias` (server-forced). - **`tickets[]`** — the issue actions from Phase 3: `{repo, action, ...}` where action ∈ create/close/comment. Owner is always `mathias` (server-forced).
- **`summary`** — `{title, body, repos_touched}`. Capture writes it to `ai-sessions` and stamps `fidelity` in frontmatter. Body stays reconstructable: one-paragraph summary, decisions, key artifacts, open threads.
- **`context`** — `{harness: "claudeai-chat", session_ref: <chatid8-or-slug>, fidelity: "live-capture", actor: "mathias", classification: <see gate below>}`. - **`context`** — `{harness: "claudeai-chat", session_ref: <chatid8-or-slug>, fidelity: "live-capture", actor: "mathias", classification: <see gate below>}`.
**THE CLASSIFICATION GATE (read before calling — this is where capture refuses).** **THE CLASSIFICATION GATE (read before calling — this is where capture refuses).**
Capture computes an **effective classification = the strictest across EVERY target it touches** (each insight's `wing`, each ticket's `repo`, and every entry in `summary.repos_touched`), then refuses if that effective level is `confidential` and the origin is us-nexus (claude.ai is us-nexus). Levels come from `classification.yaml` at the brain root (source of truth, #67), with the code defaults as the floor: `hyperguild`/`homelab` → internal; `client-*` → confidential; **anything untagged → confidential (fail-safe)**. Capture computes an **effective classification = the strictest across EVERY target it touches** (each insight's `wing`, each ticket's `repo`), then refuses if that effective level is `confidential` and the origin is us-nexus (claude.ai is us-nexus). Levels come from `classification.yaml` at the brain root (source of truth, #67), with the code defaults as the floor: `hyperguild`/`homelab` → internal; `client-*` → confidential; **anything untagged → confidential (fail-safe)**.
- **Tagged `internal` today** (safe through claude.ai): wings `hyperguild`, `homelab`; repos `brain`, `ai-sessions`, `infra`, `hyperguild`, `homelab`, `tapir`, `agentsquad`, `jepa-fx-risk`, `swedsl`. Treat `classification.yaml` as authoritative — this list is a hint, not gospel. - **Tagged `internal` today** (safe through claude.ai): wings `hyperguild`, `homelab`; repos `brain`, `ai-sessions`, `infra`, `hyperguild`, `homelab`, `tapir`, `agentsquad`, `jepa-fx-risk`, `swedsl`. Treat `classification.yaml` as authoritative — this list is a hint, not gospel.
- Declare `context.classification: "internal"` for normal homelab work. - Declare `context.classification: "internal"` for normal homelab work.
- `summary.repos_touched`, insight `wing`s, and ticket `repo`s are classification INPUTS, not free-form metadata — every target must resolve `internal` or the whole capture escalates to `confidential` and the gate refuses via claude.ai. Listing the central homelab repos (incl. `brain`/`ai-sessions`) is now fine; they're tagged. The summary always lands in `ai-sessions` (internal), so the summary path itself never escalates. - Insight `wing`s and ticket `repo`s are classification INPUTS, not free-form metadata — every target must resolve `internal` or the whole capture escalates to `confidential` and the gate refuses via claude.ai. Listing the central homelab repos (incl. `brain`/`ai-sessions`) is now fine; they're tagged.
- If a session genuinely touched **`client-*` or otherwise-untagged** material, it cannot be captured through claude.ai — note that in the verdict rather than trying to force it. - If a session genuinely touched **`client-*` or otherwise-untagged** material, it cannot be captured through claude.ai — note that in the verdict rather than trying to force it.
**GATE — dry-run first, then execute.** **GATE — dry-run first, then execute.**
1. Call `capture` with `dry_run: true`. It validates the whole payload and returns the would-be receipt + `effective_classification`, writing nothing. 1. Call `capture` with `dry_run: true`. It validates the whole payload and returns the would-be receipt + `effective_classification`, writing nothing.
2. **STOP. Show the dry-run receipt** (effective classification, the insights/tickets/summary that would land) and get explicit confirmation. 2. **STOP. Show the dry-run receipt** (effective classification, the insights/tickets that would land) and get explicit confirmation.
3. On confirmation, call `capture` again with `dry_run: false`. Read the returned receipt: it is partial-aware (`errors[]`, per-item `ok`). Report exactly what landed. 3. On confirmation, call `capture` again with `dry_run: false`. Read the returned receipt: it is partial-aware (`errors[]`, per-item `ok`). Report exactly what landed.
If `capture` is **unreachable** (tool not on the connector — e.g. a session that started before a deploy; a tool-list refresh usually fixes it): say so. Only then fall back to the legacy inline path (`gitea:file_write_branch` summary + `brain_write`/`brain_update` + `brain_get` confirm), and note in the verdict that the I5 audit record was NOT produced. If `capture` is **unreachable** (tool not on the connector — e.g. a session that started before a deploy; a tool-list refresh usually fixes it): say so. Only then fall back to the legacy inline path (`brain_write`/`brain_update` + `brain_get` confirm), and note in the verdict that the I5 audit record was NOT produced.
## Phase 5 — Verdict ## Phase 5 — Verdict
Deliver a final "safe to archive" verdict in the chat. Either: Deliver a final "safe to archive" verdict in the chat. Either:
- **SAFE TO ARCHIVE** — list what landed from the capture receipt (issues closed/filed with numbers, summary path, brain note ids/paths) so the trail is auditable. Then list anything still in the user's queue (e.g. a PR awaiting their merge, a decision owed next session). - **SAFE TO ARCHIVE** — list what landed from the capture receipt (issues closed/filed with numbers, brain note ids/paths) so the trail is auditable. Then list anything still in the user's queue (e.g. a PR awaiting their merge, a decision owed next session).
- **NOT YET** — name the specific gate that wasn't passed, the capture refusal reason, or the per-item error from the receipt, and what to do about it. - **NOT YET** — name the specific gate that wasn't passed, the capture refusal reason, or the per-item error from the receipt, and what to do about it.
Never claim safe-to-archive if the capture refused, any receipt item errored, or a gated confirmation was declined. The verdict is the skill's contract: if it says safe, the session can be lost without losing the work. Never claim safe-to-archive if the capture refused, any receipt item errored, or a gated confirmation was declined. The verdict is the skill's contract: if it says safe, the session can be lost without losing the work.
+2
View File
@@ -2,6 +2,8 @@
**Status:** Decisions resolved 2026-06-22 (§4). Ready for implementation scoping. `capture` is a **Status:** Decisions resolved 2026-06-22 (§4). Ready for implementation scoping. `capture` is a
privileged cross-harness write path touching brain + Gitea + ai-sessions. privileged cross-harness write path touching brain + Gitea + ai-sessions.
**Superseded 2026-07-27:** the ai-sessions summary write path specified below was removed post-ship
(see `specs/capture-implementation-report.md`). `capture` now touches brain + Gitea only.
**Tracks:** hyperguild #49. **Tracks:** hyperguild #49.
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1I5), the admissibility test in **Governed by:** `infra/docs/architecture/01-invariants.md` (I1I5), the admissibility test in
`00-synthesis-model.md`, and the distributed-consolidation shape mandated by `00-synthesis-model.md`, and the distributed-consolidation shape mandated by
+7
View File
@@ -1,6 +1,13 @@
# Capture capability — implementation report (as-built) # Capture capability — implementation report (as-built)
**Status:** Shipped 2026-06-23, tagged `v0.11.0`. Epic hyperguild #49 (sub-issues #50#55) closed. **Status:** Shipped 2026-06-23, tagged `v0.11.0`. Epic hyperguild #49 (sub-issues #50#55) closed.
**Superseded 2026-07-27:** the `summary``ai-sessions` write path documented below was removed.
It wrote a frontmatter shape (`title`/`harness`/`fidelity`/`captured_at`/`repos_touched`) that
`ai-sessions`' own extract/audit pipeline couldn't parse — invisible to that repo's own audit
tooling and bypassing its redaction/completeness gates (`ai-sessions#13`). `capture` now persists
insights → brain and action items → Gitea tickets only. This document is kept as the historical
as-built record of what shipped in #49; treat every `summary`/ai-sessions reference below as
retired, not current behaviour.
**Spec:** `specs/capture-bdd-spec.md` (the design contract this implements). **Spec:** `specs/capture-bdd-spec.md` (the design contract this implements).
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1I5) + the I2 acceptance ledger entry in `infra/docs/security-baseline.md`. **Governed by:** `infra/docs/architecture/01-invariants.md` (I1I5) + the I2 acceptance ledger entry in `infra/docs/security-baseline.md`.