Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6606b38a76 | ||
|
|
4cfc98de56 | ||
|
|
0ac165cca3 | ||
|
|
43f92e3102 | ||
|
|
98cfae595c | ||
|
|
2a595b5a92 | ||
|
|
b7a2cc5fdf | ||
|
|
db638cca11 | ||
|
|
38579598e0 | ||
|
|
d6fa92b176 |
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claudewatcher"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claudewatcher"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/embed"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/embed"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/gitea"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/llm"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/llm"
|
||||||
@@ -175,6 +176,15 @@ func main() {
|
|||||||
logger.Info("brain reranker configured", "url", rerankURL, "model", rerankModel)
|
logger.Info("brain reranker configured", "url", rerankURL, "model", rerankModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gitea ticket tracker for the capture capability (#52). Token via env
|
||||||
|
// only — never logged or in argv. Both vars must be set to enable it;
|
||||||
|
// gitea.New returns nil otherwise, leaving ticket integration off.
|
||||||
|
giteaURL := envOr("BRAIN_GITEA_URL", "https://git.d-ma.be")
|
||||||
|
if tracker := gitea.New(giteaURL, os.Getenv("BRAIN_GITEA_TOKEN")); tracker != nil {
|
||||||
|
mcpSrv = mcpSrv.WithIssueTracker(tracker)
|
||||||
|
logger.Info("brain gitea tracker configured", "url", giteaURL)
|
||||||
|
}
|
||||||
|
|
||||||
// Hybrid retrieval (pgvector + nomic-embed-text). Both env vars must
|
// Hybrid retrieval (pgvector + nomic-embed-text). Both env vars must
|
||||||
// be set together for the path to wire on; otherwise BM25-only.
|
// be set together for the path to wire on; otherwise BM25-only.
|
||||||
var vectorStore *vectorstore.PGStore
|
var vectorStore *vectorstore.PGStore
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Package brainstore is the concrete BrainStore: the single shared
|
||||||
|
// implementation of the #45 write/update/get verbs, used by BOTH the MCP
|
||||||
|
// handlers and the capture use-case so there is one implementation, not
|
||||||
|
// two (the Clean-Architecture / DRY payoff of #51).
|
||||||
|
//
|
||||||
|
// It composes the file-level primitives in package api (WriteNote,
|
||||||
|
// UpdateNote, ReadNote — the read-after-write contract) with the wiki
|
||||||
|
// upkeep that must accompany a write: wing _index rebuild, cross-wing
|
||||||
|
// auto-tunnel, and graph re-index. Embedding refresh is intentionally
|
||||||
|
// out-of-band (mtime-driven vectorstore.Sync) and not triggered here —
|
||||||
|
// see the brain note on out-of-band sync.
|
||||||
|
package brainstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store implements capture.BrainStore against a brain directory on disk,
|
||||||
|
// optionally re-indexing each write into the knowledge graph.
|
||||||
|
type Store struct {
|
||||||
|
brainDir string
|
||||||
|
graph graphsync.Store // nil = graph re-index disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Store bound to brainDir with graph indexing disabled.
|
||||||
|
func New(brainDir string) *Store {
|
||||||
|
return &Store{brainDir: brainDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithGraph enables graph re-index on every write/update. nil disables it.
|
||||||
|
func (s *Store) WithGraph(g graphsync.Store) *Store {
|
||||||
|
s.graph = g
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write creates a brain note and returns its read-after-write handle.
|
||||||
|
func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error) {
|
||||||
|
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||||
|
Content: n.Content,
|
||||||
|
Filename: n.Filename,
|
||||||
|
Type: n.Type,
|
||||||
|
Domain: n.Domain,
|
||||||
|
Wing: n.Wing,
|
||||||
|
Hall: n.Hall,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return capture.Ref{}, err
|
||||||
|
}
|
||||||
|
s.wikiUpkeep(relPath, n.Wing, n.Content)
|
||||||
|
s.indexInGraph(ctx, "brain_write", relPath)
|
||||||
|
|
||||||
|
_, _, hash, _ := api.ReadNote(s.brainDir, relPath)
|
||||||
|
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update supersedes an existing note in place. slug may be a bare slug
|
||||||
|
// (resolved against n.Wing/n.Hall) or a full brain-relative path (when it
|
||||||
|
// contains a slash). It never creates — a missing target is an error.
|
||||||
|
func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (capture.Ref, error) {
|
||||||
|
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason}
|
||||||
|
if strings.Contains(slug, "/") {
|
||||||
|
opts.Path = slug
|
||||||
|
} else {
|
||||||
|
opts.Wing, opts.Hall, opts.Slug = n.Wing, n.Hall, slug
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath, hash, _, err := api.UpdateNote(s.brainDir, opts)
|
||||||
|
if err != nil {
|
||||||
|
return capture.Ref{}, err
|
||||||
|
}
|
||||||
|
if wing := wingFromRelPath(relPath); wing != "" {
|
||||||
|
s.wikiUpkeep(relPath, wing, n.Content)
|
||||||
|
}
|
||||||
|
s.indexInGraph(ctx, "brain_update", relPath)
|
||||||
|
|
||||||
|
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get fetches a note by id/path — the read-after-write confirmation
|
||||||
|
// primitive (a direct fetch, never a semantic query).
|
||||||
|
func (s *Store) Get(_ context.Context, id string) (capture.StoredNote, error) {
|
||||||
|
fm, body, hash, err := api.ReadNote(s.brainDir, id)
|
||||||
|
if err != nil {
|
||||||
|
return capture.StoredNote{}, err
|
||||||
|
}
|
||||||
|
return capture.StoredNote{ID: id, Path: id, ContentHash: hash, Frontmatter: fm, Body: body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wikiUpkeep rebuilds the wing _index and re-tunnels cross-wing matches
|
||||||
|
// when a note lands in the structured wiki. Both are best-effort: the
|
||||||
|
// note is already written, so a failure here is logged, not propagated.
|
||||||
|
func (s *Store) wikiUpkeep(relPath, wing, content string) {
|
||||||
|
if wing == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := brain.BuildWingIndex(s.brainDir, wing); err != nil {
|
||||||
|
slog.Warn("brainstore: auto-index failed", "wing", wing, "err", err)
|
||||||
|
}
|
||||||
|
if err := brain.AutoTunnel(s.brainDir, relPath, content); err != nil {
|
||||||
|
slog.Warn("brainstore: auto-tunnel failed", "src", relPath, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexInGraph re-indexes a written doc into the graph, best-effort.
|
||||||
|
func (s *Store) indexInGraph(ctx context.Context, op, relPath string) {
|
||||||
|
if s.graph == nil || relPath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := graphsync.IndexDoc(ctx, s.graph, s.brainDir, relPath); err != nil {
|
||||||
|
slog.Warn(op+": graph index failed", "path", relPath, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wingFromRelPath extracts the wing from a structured wiki path
|
||||||
|
// (wiki/<wing>/<hall>/<slug>.md). Returns "" for legacy/non-wiki paths.
|
||||||
|
func wingFromRelPath(relPath string) string {
|
||||||
|
parts := strings.Split(relPath, "/")
|
||||||
|
if len(parts) >= 4 && parts[0] == "wiki" {
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package brainstore_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStoreWriteReturnsHandle(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
|
||||||
|
ref, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "# X\n\nbody\n", Filename: "x", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "wiki/a/facts/x.md", ref.Path)
|
||||||
|
assert.Equal(t, ref.Path, ref.ID)
|
||||||
|
assert.NotEmpty(t, ref.ContentHash)
|
||||||
|
assert.False(t, ref.Superseded)
|
||||||
|
|
||||||
|
_, err = os.Stat(filepath.Join(dir, "wiki/a/facts/x.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreUpdateSupersedes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
_, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "old\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ref, err := s.Update(context.Background(), "n", capture.Note{
|
||||||
|
Content: "new\n", Wing: "a", Hall: "facts", Reason: "changed",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, ref.Superseded)
|
||||||
|
assert.Equal(t, "wiki/a/facts/n.md", ref.Path)
|
||||||
|
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dir, "wiki/a/facts/n.md"))
|
||||||
|
assert.Contains(t, string(got), "new")
|
||||||
|
assert.Contains(t, string(got), "supersede_reason: changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreUpdateByFullPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
_, err := s.Write(context.Background(), capture.Note{Content: "old\n", Filename: "n", Wing: "a", Hall: "facts"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ref, err := s.Update(context.Background(), "wiki/a/facts/n.md", capture.Note{Content: "fresh\n"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "wiki/a/facts/n.md", ref.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreUpdateMissingErrors(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
_, err := s.Update(context.Background(), "ghost", capture.Note{Content: "x\n", Wing: "a", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki/a/facts/ghost.md"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "update must not create")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreGetRoundTripsHash(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
ref, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "# Body\n\ntext\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
note, err := s.Get(context.Background(), ref.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ref.ContentHash, note.ContentHash, "write→get hash round-trips")
|
||||||
|
assert.Equal(t, "a", note.Frontmatter["wing"])
|
||||||
|
assert.Contains(t, note.Body, "# Body")
|
||||||
|
}
|
||||||
@@ -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,104 @@
|
|||||||
|
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 closes an issue, optionally posting a closing comment
|
||||||
|
// first (empty comment ⇒ close only).
|
||||||
|
CloseIssue(ctx context.Context, repo string, number int, comment 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
|
||||||
|
// "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, tk.Body)
|
||||||
|
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, _ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Package classification defines the data-sensitivity taxonomy and the
|
||||||
|
// per-wing / per-repo tagging the capture server reads to enforce the I1
|
||||||
|
// sovereignty gate (issue #50, capture spec §4.1).
|
||||||
|
//
|
||||||
|
// The single load-bearing property is fail-safe-to-strictest: a target
|
||||||
|
// with no explicit tag and no known default classifies as Confidential,
|
||||||
|
// never as something more permissive. A missing tag must never silently
|
||||||
|
// downgrade — that would turn the I1 gate into theatre.
|
||||||
|
//
|
||||||
|
// Classification is read from an optional classification.yaml at the
|
||||||
|
// brain root. A central, Flux-reconcilable file is deliberate: it is
|
||||||
|
// auditable in one place (I2/I5), it does not require a live Gitea client
|
||||||
|
// to classify a repo (so this package has no dependency on the gitea
|
||||||
|
// tracker work), and it avoids tagging a wing's _index.md frontmatter —
|
||||||
|
// which BuildWingIndex regenerates and would clobber.
|
||||||
|
package classification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Level is a data-sensitivity tier. Higher is stricter, so the "stricter
|
||||||
|
// wins" rule (spec §4.1 model C) is a plain max.
|
||||||
|
type Level int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Public Level = iota
|
||||||
|
Internal
|
||||||
|
Confidential
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the canonical lowercase token for a level.
|
||||||
|
func (l Level) String() string {
|
||||||
|
switch l {
|
||||||
|
case Public:
|
||||||
|
return "public"
|
||||||
|
case Internal:
|
||||||
|
return "internal"
|
||||||
|
case Confidential:
|
||||||
|
return "confidential"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("level(%d)", int(l))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLevel parses a level token (case-insensitive, surrounding space
|
||||||
|
// tolerated). An unknown token is an error — callers must decide what to
|
||||||
|
// do with bad input rather than have it silently coerced.
|
||||||
|
func ParseLevel(s string) (Level, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
|
case "public":
|
||||||
|
return Public, nil
|
||||||
|
case "internal":
|
||||||
|
return Internal, nil
|
||||||
|
case "confidential":
|
||||||
|
return Confidential, nil
|
||||||
|
default:
|
||||||
|
return Confidential, fmt.Errorf("unknown classification level %q (want public/internal/confidential)", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stricter returns the more restrictive of two levels.
|
||||||
|
func Stricter(a, b Level) Level {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetKind distinguishes the two kinds of capture destination.
|
||||||
|
type TargetKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
WingTarget TargetKind = iota // a brain wing (insights land here)
|
||||||
|
RepoTarget // a Gitea repo (tickets / summaries land here)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Target names a capture destination to classify.
|
||||||
|
type Target struct {
|
||||||
|
Kind TargetKind
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds the explicit per-wing / per-repo classification tags read
|
||||||
|
// from classification.yaml. Absent entries fall through to the built-in
|
||||||
|
// defaults in defaultFor. The zero value (no file) is valid and applies
|
||||||
|
// defaults to everything.
|
||||||
|
type Config struct {
|
||||||
|
wings map[string]Level
|
||||||
|
repos map[string]Level
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawConfig is the on-disk YAML shape: string→string maps, parsed into
|
||||||
|
// validated levels by Load.
|
||||||
|
type rawConfig struct {
|
||||||
|
Wings map[string]string `yaml:"wings"`
|
||||||
|
Repos map[string]string `yaml:"repos"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads classification.yaml from brainDir. An absent file is not an
|
||||||
|
// error — it yields an empty config where every target classifies by the
|
||||||
|
// built-in defaults. A malformed file, or any unparseable level token in
|
||||||
|
// it, is a hard error: a classification source the server cannot trust
|
||||||
|
// must fail loud, not degrade silently.
|
||||||
|
func Load(brainDir string) (*Config, error) {
|
||||||
|
cfg := &Config{wings: map[string]Level{}, repos: map[string]Level{}}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(brainDir, "classification.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read classification.yaml: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw rawConfig
|
||||||
|
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse classification.yaml: %w", err)
|
||||||
|
}
|
||||||
|
for name, lvl := range raw.Wings {
|
||||||
|
parsed, perr := ParseLevel(lvl)
|
||||||
|
if perr != nil {
|
||||||
|
return nil, fmt.Errorf("wing %q: %w", name, perr)
|
||||||
|
}
|
||||||
|
cfg.wings[normalise(name)] = parsed
|
||||||
|
}
|
||||||
|
for name, lvl := range raw.Repos {
|
||||||
|
parsed, perr := ParseLevel(lvl)
|
||||||
|
if perr != nil {
|
||||||
|
return nil, fmt.Errorf("repo %q: %w", name, perr)
|
||||||
|
}
|
||||||
|
cfg.repos[normalise(name)] = parsed
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive returns the classification for any target — the function the
|
||||||
|
// capture use-case calls per item.
|
||||||
|
func (c *Config) Derive(t Target) Level {
|
||||||
|
if t.Kind == RepoTarget {
|
||||||
|
return c.Repo(t.Name)
|
||||||
|
}
|
||||||
|
return c.Wing(t.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wing classifies a brain wing: an explicit tag wins, else defaults.
|
||||||
|
func (c *Config) Wing(name string) Level {
|
||||||
|
if lvl, ok := c.wings[normalise(name)]; ok {
|
||||||
|
return lvl
|
||||||
|
}
|
||||||
|
return defaultFor(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repo classifies a Gitea repo: an explicit tag wins, else defaults.
|
||||||
|
func (c *Config) Repo(name string) Level {
|
||||||
|
if lvl, ok := c.repos[normalise(name)]; ok {
|
||||||
|
return lvl
|
||||||
|
}
|
||||||
|
return defaultFor(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultFor applies the built-in defaulting rules when a target has no
|
||||||
|
// explicit tag:
|
||||||
|
// - client-* → Confidential (client work is confidential by default)
|
||||||
|
// - hyperguild / homelab → Internal (the operator's own infra)
|
||||||
|
// - everything else → Confidential (fail safe to strictest)
|
||||||
|
func defaultFor(name string) Level {
|
||||||
|
n := normalise(name)
|
||||||
|
if strings.HasPrefix(n, "client-") {
|
||||||
|
return Confidential
|
||||||
|
}
|
||||||
|
switch n {
|
||||||
|
case "hyperguild", "homelab":
|
||||||
|
return Internal
|
||||||
|
default:
|
||||||
|
return Confidential
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalise lowercases and trims a wing/repo name so matching and the
|
||||||
|
// client-* prefix check are case-insensitive.
|
||||||
|
func normalise(name string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(name))
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package classification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLevelOrderingAndString(t *testing.T) {
|
||||||
|
assert.True(t, Public < Internal)
|
||||||
|
assert.True(t, Internal < Confidential)
|
||||||
|
assert.Equal(t, "public", Public.String())
|
||||||
|
assert.Equal(t, "internal", Internal.String())
|
||||||
|
assert.Equal(t, "confidential", Confidential.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLevel(t *testing.T) {
|
||||||
|
for s, want := range map[string]Level{
|
||||||
|
"public": Public, "internal": Internal, "confidential": Confidential,
|
||||||
|
"PUBLIC": Public, " Confidential ": Confidential,
|
||||||
|
} {
|
||||||
|
got, err := ParseLevel(s)
|
||||||
|
require.NoError(t, err, s)
|
||||||
|
assert.Equal(t, want, got, s)
|
||||||
|
}
|
||||||
|
_, err := ParseLevel("secret")
|
||||||
|
require.Error(t, err, "unknown level must error, not silently default")
|
||||||
|
_, err = ParseLevel("")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStricterReturnsMax(t *testing.T) {
|
||||||
|
assert.Equal(t, Confidential, Stricter(Internal, Confidential))
|
||||||
|
assert.Equal(t, Confidential, Stricter(Confidential, Public))
|
||||||
|
assert.Equal(t, Internal, Stricter(Public, Internal))
|
||||||
|
assert.Equal(t, Public, Stricter(Public, Public))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAbsentFileIsDefaultsOnly(t *testing.T) {
|
||||||
|
cfg, err := Load(t.TempDir())
|
||||||
|
require.NoError(t, err, "absent classification.yaml must not be an error — defaults apply")
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
// Pure defaulting still works.
|
||||||
|
assert.Equal(t, Internal, cfg.Wing("hyperguild"))
|
||||||
|
assert.Equal(t, Confidential, cfg.Wing("anything-unknown"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadParsesExplicitTags(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "classification.yaml"), []byte(
|
||||||
|
"wings:\n research-public: public\n hyperguild: confidential\nrepos:\n infra: internal\n research-public: public\n",
|
||||||
|
), 0o644))
|
||||||
|
|
||||||
|
cfg, err := Load(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Explicit tag wins over the built-in default (hyperguild default is internal).
|
||||||
|
assert.Equal(t, Confidential, cfg.Wing("hyperguild"))
|
||||||
|
// Explicit public is honoured.
|
||||||
|
assert.Equal(t, Public, cfg.Wing("research-public"))
|
||||||
|
assert.Equal(t, Internal, cfg.Repo("infra"))
|
||||||
|
assert.Equal(t, Public, cfg.Repo("research-public"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsUnknownLevelInFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "classification.yaml"),
|
||||||
|
[]byte("wings:\n x: top-secret\n"), 0o644))
|
||||||
|
_, err := Load(dir)
|
||||||
|
require.Error(t, err, "an unparseable level in the config must fail loud, not be ignored")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWingDefaulting(t *testing.T) {
|
||||||
|
cfg, err := Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
cases := map[string]Level{
|
||||||
|
"client-seb": Confidential, // client-* → confidential
|
||||||
|
"client-mastercard": Confidential,
|
||||||
|
"hyperguild": Internal,
|
||||||
|
"homelab": Internal,
|
||||||
|
"jepa-fx": Confidential, // unknown → fail safe to strictest
|
||||||
|
"": Confidential, // empty → fail safe
|
||||||
|
}
|
||||||
|
for wing, want := range cases {
|
||||||
|
assert.Equal(t, want, cfg.Wing(wing), "wing %q", wing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoDefaulting(t *testing.T) {
|
||||||
|
cfg, err := Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, Confidential, cfg.Repo("client-seb-pipeline"))
|
||||||
|
assert.Equal(t, Internal, cfg.Repo("hyperguild"))
|
||||||
|
assert.Equal(t, Confidential, cfg.Repo("some-unknown-repo"), "untagged repo → confidential (fail safe)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveUnifiedTarget(t *testing.T) {
|
||||||
|
cfg, err := Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, Internal, cfg.Derive(Target{Kind: WingTarget, Name: "homelab"}))
|
||||||
|
assert.Equal(t, Confidential, cfg.Derive(Target{Kind: RepoTarget, Name: "client-x"}))
|
||||||
|
assert.Equal(t, Confidential, cfg.Derive(Target{Kind: WingTarget, Name: "untagged"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaseInsensitiveMatching(t *testing.T) {
|
||||||
|
cfg, err := Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, Confidential, cfg.Wing("Client-SEB"), "client- prefix match is case-insensitive")
|
||||||
|
assert.Equal(t, Internal, cfg.Wing("HyperGuild"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Package gitea implements capture.IssueTracker against a Gitea instance
|
||||||
|
// over its REST API. It is the new outbound dependency the brain server
|
||||||
|
// gains for the capture capability (#49c/#52): the server otherwise does
|
||||||
|
// brain-local file ops only.
|
||||||
|
//
|
||||||
|
// Owner is hard-coded to the operator and never taken from caller input.
|
||||||
|
// The API token is read once at construction, held in the struct, and
|
||||||
|
// never logged or placed in argv — it travels only in the Authorization
|
||||||
|
// header of outbound requests (AGENTS.md secret-handling).
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
)
|
||||||
|
|
||||||
|
// owner is the fixed repository owner for every ticket operation. It is a
|
||||||
|
// constant, not a parameter, so a caller can never redirect a write to
|
||||||
|
// another owner's repo.
|
||||||
|
const owner = "mathias"
|
||||||
|
|
||||||
|
// Client is a Gitea REST API IssueTracker.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
token string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Client. It returns nil when either baseURL or token is
|
||||||
|
// empty, so callers can treat missing config as "tracker disabled" with a
|
||||||
|
// single nil check (mirrors embed.New).
|
||||||
|
func New(baseURL, token string) *Client {
|
||||||
|
if baseURL == "" || token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
token: token,
|
||||||
|
http: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueResponse is the subset of a Gitea issue/comment payload we read.
|
||||||
|
type issueResponse struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIssue opens a new issue under the fixed owner.
|
||||||
|
func (c *Client) CreateIssue(ctx context.Context, repo, title, body string) (capture.IssueRef, error) {
|
||||||
|
var out issueResponse
|
||||||
|
if err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo),
|
||||||
|
map[string]any{"title": title, "body": body}, &out); err != nil {
|
||||||
|
return capture.IssueRef{}, err
|
||||||
|
}
|
||||||
|
return capture.IssueRef{Repo: repo, Number: out.Number, URL: out.HTMLURL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentIssue posts a comment on an existing issue.
|
||||||
|
func (c *Client) CommentIssue(ctx context.Context, repo string, number int, body string) (capture.IssueRef, error) {
|
||||||
|
var out issueResponse
|
||||||
|
if err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number),
|
||||||
|
map[string]any{"body": body}, &out); err != nil {
|
||||||
|
return capture.IssueRef{}, err
|
||||||
|
}
|
||||||
|
return capture.IssueRef{Repo: repo, Number: number, URL: out.HTMLURL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseIssue closes an issue, first posting a closing comment when one is
|
||||||
|
// given (empty comment ⇒ close only).
|
||||||
|
func (c *Client) CloseIssue(ctx context.Context, repo string, number int, comment string) (capture.IssueRef, error) {
|
||||||
|
if strings.TrimSpace(comment) != "" {
|
||||||
|
if _, err := c.CommentIssue(ctx, repo, number, comment); err != nil {
|
||||||
|
return capture.IssueRef{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out issueResponse
|
||||||
|
if err := c.do(ctx, http.MethodPatch,
|
||||||
|
fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number),
|
||||||
|
map[string]any{"state": "closed"}, &out); err != nil {
|
||||||
|
return capture.IssueRef{}, err
|
||||||
|
}
|
||||||
|
return capture.IssueRef{Repo: repo, Number: number, URL: out.HTMLURL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// do performs a JSON request against the Gitea API and decodes the
|
||||||
|
// response into out. Errors carry the status and a truncated body for
|
||||||
|
// diagnosis but never the token.
|
||||||
|
func (c *Client) do(ctx context.Context, method, path string, payload any, out *issueResponse) error {
|
||||||
|
reqBody, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
// Gitea's token scheme. Held here only; never logged.
|
||||||
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gitea %s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("gitea %s %s: status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||||
|
}
|
||||||
|
if out != nil && len(respBody) > 0 {
|
||||||
|
if err := json.Unmarshal(respBody, out); err != nil {
|
||||||
|
return fmt.Errorf("gitea %s %s: decode response: %w", method, path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package gitea_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/gitea"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testToken = "super-secret-token-value"
|
||||||
|
|
||||||
|
func TestNewNilWhenUnconfigured(t *testing.T) {
|
||||||
|
assert.Nil(t, gitea.New("", testToken))
|
||||||
|
assert.Nil(t, gitea.New("https://git.example", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateIssueForcesOwnerAndAuth(t *testing.T) {
|
||||||
|
var gotPath, gotAuth, gotBody string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
gotBody = string(b)
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"number": 42, "html_url": "https://git.d-ma.be/mathias/hyperguild/issues/42"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.New(srv.URL, testToken)
|
||||||
|
require.NotNil(t, c)
|
||||||
|
ref, err := c.CreateIssue(context.Background(), "hyperguild", "Do the thing", "details")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/hyperguild/issues", gotPath, "owner forced to mathias")
|
||||||
|
assert.Equal(t, "token "+testToken, gotAuth)
|
||||||
|
assert.Contains(t, gotBody, "Do the thing")
|
||||||
|
assert.Equal(t, "hyperguild", ref.Repo)
|
||||||
|
assert.Equal(t, 42, ref.Number)
|
||||||
|
assert.Contains(t, ref.URL, "/issues/42")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentIssue(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"html_url": "https://git/c/1"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ref, err := gitea.New(srv.URL, testToken).CommentIssue(context.Background(), "hyperguild", 7, "a comment")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/hyperguild/issues/7/comments", gotPath)
|
||||||
|
assert.Equal(t, 7, ref.Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseIssueWithComment(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
var states []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths = append(paths, r.Method+" "+r.URL.Path)
|
||||||
|
if r.Method == http.MethodPatch {
|
||||||
|
var body map[string]any
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(b, &body)
|
||||||
|
states = append(states, body["state"].(string))
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"number": 9, "html_url": "https://git/i/9"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ref, err := gitea.New(srv.URL, testToken).CloseIssue(context.Background(), "hyperguild", 9, "closing because done")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 9, ref.Number)
|
||||||
|
// Comment posted first, then state PATCHed to closed.
|
||||||
|
assert.Contains(t, paths, "POST /api/v1/repos/mathias/hyperguild/issues/9/comments")
|
||||||
|
assert.Contains(t, paths, "PATCH /api/v1/repos/mathias/hyperguild/issues/9")
|
||||||
|
assert.Equal(t, []string{"closed"}, states)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseIssueNoComment(t *testing.T) {
|
||||||
|
var commented bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/comments") {
|
||||||
|
commented = true
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"number": 3, "html_url": "https://git/i/3"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := gitea.New(srv.URL, testToken).CloseIssue(context.Background(), "hyperguild", 3, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, commented, "empty comment ⇒ no comment POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorPathDoesNotLeakToken(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte("boom"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := gitea.New(srv.URL, testToken).CreateIssue(context.Background(), "hyperguild", "t", "b")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), testToken, "token must never appear in an error message")
|
||||||
|
assert.Contains(t, err.Error(), "500")
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
@@ -219,7 +219,11 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
|||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
}
|
}
|
||||||
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
// Delegate to the shared BrainStore so write+index+tunnel+graph live in
|
||||||
|
// one implementation (capture uses the same store). The read-after-write
|
||||||
|
// handle {id, path, content_hash} comes back from the store; path is kept
|
||||||
|
// for backward compatibility.
|
||||||
|
ref, err := s.store.Write(ctx, capture.Note{
|
||||||
Content: a.Content,
|
Content: a.Content,
|
||||||
Filename: a.Filename,
|
Filename: a.Filename,
|
||||||
Type: a.Type,
|
Type: a.Type,
|
||||||
@@ -230,22 +234,7 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Auto-regenerate the wing _index.md when the write landed in the
|
return json.Marshal(map[string]string{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash})
|
||||||
// structured wiki, and auto-tunnel cross-wing matches. Both are
|
|
||||||
// best-effort: the note is already written.
|
|
||||||
if a.Wing != "" && a.Hall != "" {
|
|
||||||
if err := brain.BuildWingIndex(s.brainDir, a.Wing); err != nil {
|
|
||||||
slog.Warn("brain_write: auto-index failed", "wing", a.Wing, "err", err)
|
|
||||||
}
|
|
||||||
if err := brain.AutoTunnel(s.brainDir, relPath, a.Content); err != nil {
|
|
||||||
slog.Warn("brain_write: auto-tunnel failed", "src", relPath, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.indexInGraph(ctx, "brain_write", relPath)
|
|
||||||
// Read-after-write handle: id == relPath, content_hash == sha256 of
|
|
||||||
// the bytes just written. path is kept for backward compatibility.
|
|
||||||
_, _, hash, _ := api.ReadNote(s.brainDir, relPath)
|
|
||||||
return json.Marshal(map[string]string{"id": relPath, "path": relPath, "content_hash": hash})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type brainUpdateArgs struct {
|
type brainUpdateArgs struct {
|
||||||
@@ -274,50 +263,26 @@ func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.Ra
|
|||||||
return nil, fmt.Errorf("content is required")
|
return nil, fmt.Errorf("content is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := api.UpdateNoteOptions{Content: a.Content, Reason: a.Reason}
|
// path takes precedence over slug; the store treats any slug containing
|
||||||
switch {
|
// a slash as a full brain-relative path (issue #45: "slug ... OR path").
|
||||||
case a.Path != "":
|
slug := a.Slug
|
||||||
opts.Path = a.Path
|
if a.Path != "" {
|
||||||
case strings.Contains(a.Slug, "/"):
|
slug = a.Path
|
||||||
// slug carries a full path (issue #45: "slug ... OR full path").
|
|
||||||
opts.Path = a.Slug
|
|
||||||
default:
|
|
||||||
opts.Wing, opts.Hall, opts.Slug = a.Wing, a.Hall, a.Slug
|
|
||||||
}
|
}
|
||||||
|
ref, err := s.store.Update(ctx, slug, capture.Note{
|
||||||
relPath, hash, _, err := api.UpdateNote(s.brainDir, opts)
|
Content: a.Content,
|
||||||
|
Wing: a.Wing,
|
||||||
|
Hall: a.Hall,
|
||||||
|
Reason: a.Reason,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort wiki upkeep, mirroring brain_write: rebuild the wing
|
|
||||||
// _index and re-tunnel cross-wing matches against the new body. Both
|
|
||||||
// are idempotent and never block — the note is already superseded.
|
|
||||||
if wing := wingFromRelPath(relPath); wing != "" {
|
|
||||||
if err := brain.BuildWingIndex(s.brainDir, wing); err != nil {
|
|
||||||
slog.Warn("brain_update: auto-index failed", "wing", wing, "err", err)
|
|
||||||
}
|
|
||||||
if err := brain.AutoTunnel(s.brainDir, relPath, a.Content); err != nil {
|
|
||||||
slog.Warn("brain_update: auto-tunnel failed", "src", relPath, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.indexInGraph(ctx, "brain_update", relPath)
|
|
||||||
|
|
||||||
return json.Marshal(map[string]any{
|
return json.Marshal(map[string]any{
|
||||||
"id": relPath, "path": relPath, "content_hash": hash, "superseded": true,
|
"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash, "superseded": ref.Superseded,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// wingFromRelPath extracts the wing segment from a structured wiki path
|
|
||||||
// (wiki/<wing>/<hall>/<slug>.md). Returns "" for legacy/non-wiki paths.
|
|
||||||
func wingFromRelPath(relPath string) string {
|
|
||||||
parts := strings.Split(relPath, "/")
|
|
||||||
if len(parts) >= 4 && parts[0] == "wiki" {
|
|
||||||
return parts[1]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type brainGetArgs struct {
|
type brainGetArgs struct {
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
@@ -327,7 +292,7 @@ type brainGetArgs struct {
|
|||||||
// path — the de-facto handle). Read-only; the create-path read-after-
|
// path — the de-facto handle). Read-only; the create-path read-after-
|
||||||
// write primitive that lets callers confirm a write landed without a
|
// write primitive that lets callers confirm a write landed without a
|
||||||
// lexical re-query.
|
// lexical re-query.
|
||||||
func (s *Server) brainGet(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
func (s *Server) brainGet(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
var a brainGetArgs
|
var a brainGetArgs
|
||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
@@ -339,13 +304,13 @@ func (s *Server) brainGet(_ context.Context, args json.RawMessage) (json.RawMess
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
return nil, fmt.Errorf("id or path is required")
|
return nil, fmt.Errorf("id or path is required")
|
||||||
}
|
}
|
||||||
fm, body, hash, err := api.ReadNote(s.brainDir, target)
|
note, err := s.store.Get(ctx, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return json.Marshal(map[string]any{
|
return json.Marshal(map[string]any{
|
||||||
"id": target, "path": target, "content_hash": hash,
|
"id": note.ID, "path": note.Path, "content_hash": note.ContentHash,
|
||||||
"frontmatter": fm, "body": body,
|
"frontmatter": note.Frontmatter, "body": note.Body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
@@ -46,6 +48,8 @@ type Server struct {
|
|||||||
vector search.VectorSearcher // nil = BM25-only retrieval
|
vector search.VectorSearcher // nil = BM25-only retrieval
|
||||||
embedder search.Embedder // nil = BM25-only retrieval
|
embedder search.Embedder // nil = BM25-only retrieval
|
||||||
graph graphsync.Store // nil = brain_graph and GraphRAG augmentation disabled
|
graph graphsync.Store // nil = brain_graph and GraphRAG augmentation disabled
|
||||||
|
store *brainstore.Store // shared brain write/update/get impl (also used by capture)
|
||||||
|
tracker capture.IssueTracker // nil = no Gitea ticket integration; wired for capture (#53)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
||||||
@@ -56,7 +60,13 @@ func NewServer(brainDir string, pipelineCfg *pipeline.Config, llm pipeline.Compl
|
|||||||
if pipelineCfg != nil {
|
if pipelineCfg != nil {
|
||||||
cfg = *pipelineCfg
|
cfg = *pipelineCfg
|
||||||
}
|
}
|
||||||
return &Server{brainDir: brainDir, pipeline: cfg, llm: llm, answerLLM: answerLLM}
|
return &Server{
|
||||||
|
brainDir: brainDir,
|
||||||
|
pipeline: cfg,
|
||||||
|
llm: llm,
|
||||||
|
answerLLM: answerLLM,
|
||||||
|
store: brainstore.New(brainDir),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithReranker installs an opt-in cross-encoder reranker. When set,
|
// WithReranker installs an opt-in cross-encoder reranker. When set,
|
||||||
@@ -84,12 +94,28 @@ func (s *Server) WithHybridRetrieval(v search.VectorSearcher, e search.Embedder)
|
|||||||
func (s *Server) WithGraph(g *graphstore.PGStore) *Server {
|
func (s *Server) WithGraph(g *graphstore.PGStore) *Server {
|
||||||
if g == nil {
|
if g == nil {
|
||||||
s.graph = nil
|
s.graph = nil
|
||||||
|
s.store.WithGraph(nil)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
s.graph = g
|
s.graph = g
|
||||||
|
s.store.WithGraph(g)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithIssueTracker injects the Gitea ticket tracker behind the
|
||||||
|
// capture.IssueTracker interface. nil leaves ticket integration off. The
|
||||||
|
// use-case (capture) consumes this in #53; it is wired here so the
|
||||||
|
// dependency is constructed once and stays swappable/testable.
|
||||||
|
func (s *Server) WithIssueTracker(t capture.IssueTracker) *Server {
|
||||||
|
s.tracker = t
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueTracker returns the injected ticket tracker (nil when unconfigured).
|
||||||
|
func (s *Server) IssueTracker() capture.IssueTracker {
|
||||||
|
return s.tracker
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
// MCP streamable HTTP: GET establishes the SSE stream for server-to-client events.
|
// MCP streamable HTTP: GET establishes the SSE stream for server-to-client events.
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package mcp_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -93,3 +95,22 @@ func TestServerUnknownMethodReturnsError(t *testing.T) {
|
|||||||
assert.Equal(t, float64(-32601), errObj["code"])
|
assert.Equal(t, float64(-32601), errObj["code"])
|
||||||
assert.Contains(t, errObj["message"].(string), "unknown/method")
|
assert.Contains(t, errObj["message"].(string), "unknown/method")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type stubTracker struct{}
|
||||||
|
|
||||||
|
func (stubTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||||
|
return capture.IssueRef{}, nil
|
||||||
|
}
|
||||||
|
func (stubTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
|
return capture.IssueRef{}, nil
|
||||||
|
}
|
||||||
|
func (stubTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
|
return capture.IssueRef{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithIssueTrackerInjects(t *testing.T) {
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
assert.Nil(t, srv.IssueTracker(), "tracker is off by default")
|
||||||
|
srv = srv.WithIssueTracker(stubTracker{})
|
||||||
|
assert.NotNil(t, srv.IssueTracker(), "tracker injected behind the interface")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
name: close-session
|
||||||
|
description: Disciplined end-of-session closeout for a Claude.ai chat before archiving it. Harvests the session's decisions, artifacts, and open threads and durably persists them to the brain MCP and the right Gitea repo so nothing is lost when context resets. Use this whenever the user signals they are wrapping up — phrases like "close this out", "let's wrap up", "before I archive", "session retro", "capture this before I go", "did we lose anything", or any end-of-session/handoff cue — even if they don't say the word "close". Also use when the user explicitly asks to retro, archive, or hand off a working session.
|
||||||
|
---
|
||||||
|
|
||||||
|
# close-session
|
||||||
|
|
||||||
|
Capture a finishing Claude.ai work session into durable storage before the chat is archived and its context is lost. The goal is simple and load-bearing: **after this runs, a fresh session (or another agent) can reconstruct what was decided, what was shipped, and what is still open — without the original chat.**
|
||||||
|
|
||||||
|
This skill is **batch**: one session in, findings out, done. It does not loop or re-read its own fresh output semantically (see Phase 5). Run the phases in order. Stop at any confirmation gate that says STOP.
|
||||||
|
|
||||||
|
## Operating constraints (read first)
|
||||||
|
|
||||||
|
- **Gitea owner is always `mathias`.** Never guess another owner.
|
||||||
|
- **Ground-truth at HEAD before acting.** Issue bodies and doc references rot — stale hostnames, retired services, moved endpoints. Before closing/commenting on any issue, `gitea:issue_get` it fresh. Before asserting an infra fact, verify it; do not copy it from memory or from a stale issue body.
|
||||||
|
- **Current infra truths** (verify rather than trust, but these are the known-good baseline): Gitea is `git.d-ma.be` (not `gitea.d-ma.be`). LiteLLM is `http://koala:30401/v1/` (public `https://llm-api.d-ma.be`); piguard runs NGINX Proxy Manager only — never reference `piguard:4000` or `koala:4000`. Identity provider is Authentik (Dex migration complete).
|
||||||
|
- **Side-effects need a confirmation gate.** Closing issues, committing files, and writing to the brain are all real writes. Surface exactly what will happen and get a clear yes before doing it. Reads are free; writes are gated.
|
||||||
|
- **Never fabricate.** If the session didn't produce a decision worth persisting, say so and skip that write. An empty-but-honest closeout beats an invented one.
|
||||||
|
|
||||||
|
## Phase 1 — Harvest
|
||||||
|
|
||||||
|
Reconstruct what actually happened this session from the conversation itself. Produce, in working memory:
|
||||||
|
|
||||||
|
- **Decisions taken** — what was decided and the reasoning, not just the outcome.
|
||||||
|
- **Artifacts produced** — issues filed/closed, PRs opened/merged, files committed, brain notes written, ADRs. Capture identifiers (issue numbers, PR numbers, paths, commit SHAs) as you go.
|
||||||
|
- **Open threads** — what was deferred, what's blocked, what the next session should pick up.
|
||||||
|
- **Generalizable learnings** — reusable patterns or footguns that would bite anyone again (these are brain-worthy; project status is not).
|
||||||
|
|
||||||
|
Be honest about fidelity: a long session compresses harder at the start than the end. Flag anything you're reconstructing rather than certain of.
|
||||||
|
|
||||||
|
## Phase 2 — Ground-truth Gitea state
|
||||||
|
|
||||||
|
For every repo touched this session, get its true current state before proposing any change. `gitea:repo_status` (owner `mathias`) gives branches + open PRs + protection in one call. For each issue you intend to close, comment on, or reference: `gitea:issue_get` it fresh and compare to what the session assumed. Note any drift (closed-already, body rotted, renamed) — you'll surface it in Phase 3.
|
||||||
|
|
||||||
|
Do not write anything in this phase. This is the read pass.
|
||||||
|
|
||||||
|
## Phase 3 — Confirm and act on issue changes
|
||||||
|
|
||||||
|
Present a single consolidated plan of issue actions: which to close (with closing comment), which to file (discovered-but-deferred work — token-budget gaps, recorded limitations, v2 follow-ups), which to comment on. Include the exact title/body for any new issue and the closing rationale for any close.
|
||||||
|
|
||||||
|
**GATE — STOP and get explicit confirmation before any issue write.** Issue closes and new issues are side-effects. Once confirmed, execute them (`gitea:issue_close`, `gitea:issue_create`, `gitea:issue_comment`, all owner `mathias`), correcting any rotted references you found in Phase 2 as you go.
|
||||||
|
|
||||||
|
## Phase 4 — Commit the canonical session summary
|
||||||
|
|
||||||
|
Write one summary file to `mathias/ai-sessions`, committed directly to `main` via `gitea:file_write_branch` (no PR — this repo is solo and unprotected; if branch protection is ever added, fall back to a branch + PR).
|
||||||
|
|
||||||
|
**Path:** `summaries/claudeai/<YYYY-MM>/<YYYY-MM-DD>-<topic-slug>-<chatid8>.md`
|
||||||
|
where `<chatid8>` is the first 8 chars of the chat's UUID if known, else a short stable slug. `claudeai` has no host segment — Claude.ai is Anthropic-side, not a homelab host.
|
||||||
|
|
||||||
|
**Frontmatter — the REDUCED live-capture schema.** A live close-session capture cannot populate the batch-export telemetry (token counts, message counts, duration_ms, permission_mode) — those only exist in the account export pipeline. Write only what's truthfully known, and mark fidelity so a reader (or the batch pipeline) can tell a live capture from an export:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: "<concise session title>"
|
||||||
|
client: "claudeai"
|
||||||
|
interface: "claudeai-chat"
|
||||||
|
date: "<YYYY-MM-DD>"
|
||||||
|
repos_touched: [<repo slugs>]
|
||||||
|
topic_tags: [<tags>]
|
||||||
|
outcome: "<shipped|in-progress|abandoned>"
|
||||||
|
fidelity: "live-capture" # NOT an export; reconstructed live from chat
|
||||||
|
captured_by: "close-session-skill"
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not invent the export-only fields. `fidelity: live-capture` is the honest signal; if the batch export later produces a richer summary for the same session, the export is source of truth and supersedes this.
|
||||||
|
|
||||||
|
**Body** (keep it reconstructable, not exhaustive):
|
||||||
|
```markdown
|
||||||
|
## One-paragraph summary
|
||||||
|
## Decisions
|
||||||
|
## Key artifacts
|
||||||
|
## Open threads
|
||||||
|
```
|
||||||
|
|
||||||
|
**GATE — STOP, show the full file (path + frontmatter + body), get explicit confirmation before committing.**
|
||||||
|
|
||||||
|
## Phase 5 — Brain orientation note (the durable "where we are" record)
|
||||||
|
|
||||||
|
Write one brain note so a fresh session can orient without the chat. This uses the `brain_update`/`brain_get` verbs (live since 2026-06).
|
||||||
|
|
||||||
|
**Target:** `wing: <domain>` (the project/topic domain, e.g. `hyperguild`, `jepa-fx`), `hall: decisions`. The note is a knowledge-type record (a decision/orientation), grouped by knowledge-type, not by interface surface.
|
||||||
|
|
||||||
|
**Batch read-after-write discipline (important — do these in order, do not interleave):**
|
||||||
|
|
||||||
|
1. **Read first, before any write.** Check whether an orientation note already exists for this wing/topic. Do your "does this already exist / what should I supersede" reads NOW, up front. BM25/keyword search and `brain_get` are immediate; semantic/vector search may lag up to ~5 min after a write, so never rely on a semantic query to find something you wrote earlier in this same run.
|
||||||
|
2. **Write or supersede:**
|
||||||
|
- **New note** → `brain_write` (wing, hall: decisions). Returns `{id, path, content_hash}`.
|
||||||
|
- **Superseding a prior orientation note** → `brain_update` (slug or path, wing, hall, content, reason). Whole-note replace; stamps `supersedes`/`updated_at`; returns `{id, path, content_hash, superseded}`. Use this instead of a second `brain_write` to the same slug — blind re-write creates duplicates/contradictions, which is the exact failure brain_update exists to prevent.
|
||||||
|
3. **Confirm it landed** via `brain_get(id)` and check the returned `content_hash` matches what the write returned. This is the read-after-write confirmation — do it with `brain_get`, never a semantic query.
|
||||||
|
|
||||||
|
**RULE: no semantic/vector brain query after the first `brain_update` in this run.** The batch shape makes this natural — read up front, write, confirm by id. If you ever find the skill wanting to semantic-search a just-superseded note, stop and flag it (that's the signal the staleness window matters and needs the synchronous-reembed follow-up).
|
||||||
|
|
||||||
|
**GATE — STOP, show the note (target wing/hall, new-vs-supersede, full content), get explicit confirmation before the brain write.**
|
||||||
|
|
||||||
|
After the note lands, if it relates to a note in another wing, create the cross-link inline with `brain_tunnel(source, target)` (idempotent; both paths brain-relative, must be in different wings). Optionally append a `session_log` entry (`session_id`, `skill: close-session`, `phase`, `final_status`) for telemetry. Both are now callable directly from Claude.ai — no Claude Code/Crush handoff needed.
|
||||||
|
|
||||||
|
## Phase 6 — Verdict
|
||||||
|
|
||||||
|
Deliver a final "safe to archive" verdict in the chat. Either:
|
||||||
|
|
||||||
|
- **SAFE TO ARCHIVE** — list what landed (issues closed/filed with numbers, summary path, brain note id, any tunnels) 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 or the write that failed, and what to do about it.
|
||||||
|
|
||||||
|
Never claim safe-to-archive if any gated write was declined or errored. The verdict is the skill's contract: if it says safe, the session can be lost without losing the work.
|
||||||
|
|
||||||
|
## Why the gates and the batch discipline matter
|
||||||
|
|
||||||
|
The whole point is durability across a context reset. Every gate is a place where a wrong write would silently corrupt the record (close the wrong issue, overwrite a good brain note, commit a half-truth). The batch read-discipline in Phase 5 exists because the brain's vector index refreshes out-of-band: write-then-semantically-reread in the same run can read stale, so the skill front-loads reads and confirms writes by id. Get those right and the skill does what it promises — nothing important is lost when the chat goes away.
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
# Capture capability — use-case & BDD specification
|
||||||
|
|
||||||
|
**Status:** Decisions resolved 2026-06-22 (§4). Ready for implementation scoping. `capture` is a
|
||||||
|
privileged cross-harness write path touching brain + Gitea + ai-sessions.
|
||||||
|
**Tracks:** hyperguild #49.
|
||||||
|
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1–I5), the admissibility test in
|
||||||
|
`00-synthesis-model.md`, and the distributed-consolidation shape mandated by
|
||||||
|
`brain/wiki/homelab/decisions/no-centralized-cross-harness-observer-2026-06-17.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Use-case (Clean Architecture form)
|
||||||
|
|
||||||
|
**Name:** CaptureSession
|
||||||
|
**Actor:** A harness acting on the user's behalf (claude.ai Chat/Cowork/Code/Design, Claude Code
|
||||||
|
CLI, Crush, Pi, LLM Council, Agentsquad executor/reviewer) — or the user directly.
|
||||||
|
**Goal:** Durably persist a finished session's valuable output — insights → brain, action items →
|
||||||
|
Gitea tickets, optional summary → ai-sessions — with one uniform invocation, identical core
|
||||||
|
behaviour across harnesses.
|
||||||
|
|
||||||
|
**Primary success scenario (essential steps):**
|
||||||
|
1. Caller assembles capture input (insights, tickets, optional summary) + context (harness,
|
||||||
|
session_ref, fidelity, actor, **data-classification**).
|
||||||
|
2. System validates the whole request (fail-closed).
|
||||||
|
3. System resolves **effective classification** (stricter of caller-declared and target-derived)
|
||||||
|
and the **server-derived harness origin** (from the authenticated principal). It checks the
|
||||||
|
**sovereignty gate** (I1): if effective classification is confidential AND the origin is a
|
||||||
|
non-sovereign (us-nexus) surface, the capture is **refused** before any write.
|
||||||
|
4. System persists insights (write or supersede), tickets (create/close/comment), summary — each
|
||||||
|
best-effort, recording per-item outcome.
|
||||||
|
5. System emits an **audit record** (I5) of who/what captured what, when, via which principal.
|
||||||
|
6. System returns a structured, partial-aware receipt.
|
||||||
|
|
||||||
|
**Architectural shape:** the *logic* is a shared use-case (`CaptureService`), invoked **per-harness
|
||||||
|
against the caller's own credentials** (distributed consolidation — no high-degree observer node).
|
||||||
|
A central authenticated relay endpoint exists ONLY as a fallback for harnesses that cannot run the
|
||||||
|
use-case in-process (Crush/Pi/headless); the relay holds no standing visibility and retains nothing
|
||||||
|
beyond the I5 audit log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Invariant obligations (acceptance gates, not nice-to-haves)
|
||||||
|
|
||||||
|
| Invariant | Obligation on `capture` |
|
||||||
|
|---|---|
|
||||||
|
| **I1 sovereign containment** | A confidential-classified session MUST NOT be captured through a us-nexus harness. Harness origin is **server-derived from the authenticated principal** (not caller-asserted). Classification uses **model (C)**: caller declares, server cross-checks the target's tag, **stricter wins**, mismatch logged. See §4.1–4.2. |
|
||||||
|
| **I2 deliberate acceptance** | The *distributed-library* form opens no new acceptance. IF a central relay node is deployed, its cross-harness reach MUST be entered in `infra/docs/security-baseline.md` with Why-accepted / Revisit-if before it ships. |
|
||||||
|
| **I3 GitOps reconcilability** | IF `capture` runs as a deployed service, its manifest lives under `infra/k3s/apps/**` (sovereign source, Flux-reconciled). No untracked runtime. |
|
||||||
|
| **I4 decisions captured** | The distributed-vs-central decision and the intent-named-verb pattern are recorded (ADR + brain). |
|
||||||
|
| **I5 auditability** | Every capture emits a request-level audit record (actor/principal, harness, items written, timestamp) to the alloy/loki substrate. **Classification-aware degradation** (§4.4): confidential + sink-down → hard-refuse; internal/public + sink-down → durable local buffer + ntfy + reconcile. Floor: refuse if nothing can record the audit. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. BDD scenarios (Gherkin)
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Feature: Capture session value uniformly across harnesses
|
||||||
|
As an operator working across many AI harnesses
|
||||||
|
I want one uniform command to persist insights and file tickets
|
||||||
|
So that valuable session output is never lost and is always auditable
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given a brain store, a Gitea issue tracker, and an ai-sessions summary writer
|
||||||
|
And the caller is authenticated with a principal
|
||||||
|
And the session context declares a harness, a fidelity, and a data classification
|
||||||
|
|
||||||
|
# --- Core happy path ---
|
||||||
|
Scenario: Capture insights and tickets from a non-confidential session
|
||||||
|
Given a session classified as "internal"
|
||||||
|
And the capture input has 2 insights and 1 ticket to create
|
||||||
|
When capture is invoked
|
||||||
|
Then both insights are written to the brain and their ids and content hashes are returned
|
||||||
|
And the ticket is created in the named repo under owner "mathias"
|
||||||
|
And an audit record is emitted naming the principal, harness, and items written
|
||||||
|
And the receipt reports every item as ok
|
||||||
|
|
||||||
|
# --- I1: sovereignty gate (the load-bearing refusal) ---
|
||||||
|
# Harness origin is server-derived from the authenticated principal, never from context.harness.
|
||||||
|
Scenario: Refuse capture of a confidential session through a us-nexus harness
|
||||||
|
Given a session whose effective classification is "confidential"
|
||||||
|
And the authenticated principal resolves to a us-nexus harness origin
|
||||||
|
When capture is invoked
|
||||||
|
Then the capture is refused before any write
|
||||||
|
And no insight, ticket, or summary is persisted
|
||||||
|
And the refusal names the sovereignty invariant as the reason
|
||||||
|
|
||||||
|
Scenario: Allow capture of a confidential session through a sovereign harness
|
||||||
|
Given a session whose effective classification is "confidential"
|
||||||
|
And the authenticated principal resolves to a sovereign-soil harness origin
|
||||||
|
When capture is invoked
|
||||||
|
Then the capture proceeds and persists normally
|
||||||
|
|
||||||
|
Scenario: Ignore a caller-asserted harness label and use the server-derived origin
|
||||||
|
Given the request context asserts harness "sovereign-soil"
|
||||||
|
But the authenticated principal resolves to a us-nexus origin
|
||||||
|
And the session classification is "confidential"
|
||||||
|
When capture is invoked
|
||||||
|
Then the capture is refused
|
||||||
|
And the server-derived origin is used, not the asserted label
|
||||||
|
And the asserted-vs-derived discrepancy is logged as a security event
|
||||||
|
|
||||||
|
# --- I1: classification model (C) — stricter of declared vs target-derived wins ---
|
||||||
|
Scenario: Take the stricter classification when caller and target disagree
|
||||||
|
Given the caller declares classification "internal"
|
||||||
|
But the target wing/repo is tagged "confidential"
|
||||||
|
When capture is invoked
|
||||||
|
Then the effective classification is "confidential"
|
||||||
|
And the declared-vs-derived mismatch is logged as a security event
|
||||||
|
And the I1 gate is evaluated against "confidential"
|
||||||
|
|
||||||
|
Scenario: Honour a caller raising sensitivity above the target's tag
|
||||||
|
Given the caller declares classification "confidential"
|
||||||
|
And the target wing/repo is tagged "internal"
|
||||||
|
When capture is invoked
|
||||||
|
Then the effective classification is "confidential"
|
||||||
|
And the capture is gated as confidential
|
||||||
|
|
||||||
|
# --- Supersession + staleness discipline (reuses #45 / #47 resolution) ---
|
||||||
|
Scenario: Supersede a prior insight rather than duplicating it
|
||||||
|
Given an insight whose context names an existing note to supersede
|
||||||
|
When capture is invoked
|
||||||
|
Then the existing note is updated in place, not duplicated
|
||||||
|
And the prior content hash is recorded in the superseding note
|
||||||
|
And read-after-write confirmation uses a direct fetch, never a semantic query
|
||||||
|
|
||||||
|
# --- Validation: fail-closed ---
|
||||||
|
Scenario: Reject a malformed request before any write
|
||||||
|
Given a capture input with an invalid wing/hall or unknown repo
|
||||||
|
When capture is invoked
|
||||||
|
Then the request is rejected with a validation error
|
||||||
|
And nothing is written to the brain, Gitea, or ai-sessions
|
||||||
|
|
||||||
|
# --- Partial failure: best-effort + honest receipt ---
|
||||||
|
Scenario: Report partial success when one item fails mid-capture
|
||||||
|
Given a capture input with 2 insights and 1 ticket
|
||||||
|
And the second insight write will fail
|
||||||
|
When capture is invoked
|
||||||
|
Then the first insight and the ticket are persisted
|
||||||
|
And the second insight is reported as failed in the receipt
|
||||||
|
And no rollback is attempted
|
||||||
|
And the audit record reflects exactly what landed
|
||||||
|
|
||||||
|
# --- Dry run ---
|
||||||
|
Scenario: Preview a capture without writing
|
||||||
|
Given a valid capture input with dry_run true
|
||||||
|
When capture is invoked
|
||||||
|
Then the would-be receipt is returned
|
||||||
|
And nothing is written anywhere
|
||||||
|
|
||||||
|
# --- I5: auditability is classification-aware (confidential fails closed) ---
|
||||||
|
Scenario: Confidential capture hard-refuses when the central audit sink is down
|
||||||
|
Given the effective classification is "confidential"
|
||||||
|
And the central audit substrate (loki) cannot be written to
|
||||||
|
When capture is invoked
|
||||||
|
Then the capture is refused before any write
|
||||||
|
And the reason names the auditability invariant
|
||||||
|
# Confidential work must be centrally auditable at write time — no buffered exception.
|
||||||
|
|
||||||
|
Scenario: Internal capture degrades to a durable local buffer when the sink is down
|
||||||
|
Given the effective classification is "internal" or "public"
|
||||||
|
And the central audit substrate (loki) cannot be written to
|
||||||
|
When capture is invoked
|
||||||
|
Then the capture proceeds
|
||||||
|
And the audit record is written to a durable LOCAL fallback buffer
|
||||||
|
And an ntfy alert is emitted naming the degraded audit state
|
||||||
|
And the receipt flags that audit was buffered locally, not centrally recorded
|
||||||
|
|
||||||
|
Scenario: Locally buffered audit records reconcile to the central sink on recovery
|
||||||
|
Given internal-tier audit records were buffered locally during a sink outage
|
||||||
|
When the central audit substrate becomes reachable again
|
||||||
|
Then the buffered records are replayed to the central sink
|
||||||
|
And the local buffer is cleared only after confirmed central write
|
||||||
|
|
||||||
|
Scenario: Even internal capture refuses if neither sink nor local buffer can be written
|
||||||
|
Given the effective classification is "internal" or "public"
|
||||||
|
And neither the central sink nor the local fallback buffer can be written
|
||||||
|
When capture is invoked
|
||||||
|
Then the capture is refused
|
||||||
|
And the reason names the auditability invariant
|
||||||
|
# Degrade-and-warn has a floor: if NOTHING can record the audit, do not write.
|
||||||
|
|
||||||
|
# --- Summary fidelity (collision rule from the retro work) ---
|
||||||
|
Scenario: A richer-fidelity summary supersedes a thinner one for the same session
|
||||||
|
Given a summary already exists for session_ref X at fidelity "live-capture"
|
||||||
|
And a new summary arrives for session_ref X at fidelity "transcript-parse"
|
||||||
|
When capture is invoked
|
||||||
|
Then the transcript-parse summary supersedes the live-capture one
|
||||||
|
And the live-capture summary is not left as a contradicting duplicate
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Resolved decisions (2026-06-22)
|
||||||
|
|
||||||
|
These were open questions at draft; resolved in the 2026-06-22 review session. Recorded here as
|
||||||
|
binding design decisions for the build.
|
||||||
|
|
||||||
|
1. **Classification trust — model (C): caller-declares + server-cross-checks, stricter wins.**
|
||||||
|
The caller declares `context.classification`; the server **independently derives** the target's
|
||||||
|
classification (from the target wing/repo's classification tag) and gates on the **stricter of
|
||||||
|
the two**. The caller can voluntarily *raise* sensitivity but can never *lower* it below the
|
||||||
|
target's floor. A declared-vs-derived **mismatch is logged as a security event** (I5).
|
||||||
|
- **Prerequisite (new build work):** a classification taxonomy (e.g. `public` /
|
||||||
|
`internal` / `confidential`) and a per-wing / per-repo classification tag the server can read.
|
||||||
|
This must exist before the I1 gate is load-bearing. Tracked as a sub-task of #49.
|
||||||
|
- **Implemented (#50):** taxonomy `public < internal < confidential` (ordered so "stricter wins"
|
||||||
|
is `max`) in `ingestion/internal/classification/`. Tags are read from an optional
|
||||||
|
`classification.yaml` at the brain root (`wings:` / `repos:` maps); absent entries fall to
|
||||||
|
built-in defaults (`client-*` → confidential; `hyperguild`/`homelab` → internal; everything
|
||||||
|
else → **confidential, fail-safe**). `Config.Derive(Target)` is the function the use-case
|
||||||
|
calls. See brain `wiki/hyperguild/decisions/capture-classification-taxonomy`.
|
||||||
|
- Rationale: composes with decision 2; fails safe; honours a caller flagging something *more*
|
||||||
|
sensitive than its destination. Pure caller-trust (A) was rejected — it makes the gate theatre.
|
||||||
|
|
||||||
|
2. **Sovereign-harness determination — server-derived, not caller-asserted.**
|
||||||
|
"Is this harness us-nexus / sovereign?" is derived from the **authenticated principal/origin**
|
||||||
|
(the OAuth2 identity), never from `context.harness`. `context.harness` survives only as a
|
||||||
|
self-reported label for the audit log — descriptive telemetry, **never a gate input**. A control
|
||||||
|
keyed on an attacker-suppliable value is not a control.
|
||||||
|
|
||||||
|
3. **Central relay — ships in v1, with the I2 ledger entry.**
|
||||||
|
The relay is required, not optional: claude.ai (Chat/Cowork/Design), Crush, Pi, and LLM Council
|
||||||
|
cannot run the use-case library in-process, and those are primary day-to-day surfaces. Deferring
|
||||||
|
the relay would ship a capability that doesn't work from the interfaces actually in use. Because
|
||||||
|
the relay is a (thin, no-standing-visibility, audit-only-retention) central node, its cross-harness
|
||||||
|
reach **must be entered in `infra/docs/security-baseline.md`** with Why-accepted / Revisit-if
|
||||||
|
**before it ships** (I2). That ledger entry is v1 work, not a follow-up.
|
||||||
|
|
||||||
|
4. **Audit-sink-down — classification-aware: confidential fails closed, internal/public degrades.**
|
||||||
|
The posture inherits from the effective classification (decision 1), so there is one coherent
|
||||||
|
sensitivity model rather than a separate availability policy:
|
||||||
|
- **Confidential + central audit sink unreachable → hard-refuse.** No buffer, no proceed.
|
||||||
|
Confidential work must be centrally auditable *at write time*; "buffer and reconcile later"
|
||||||
|
introduces a buffer-integrity question (can a write tamper with its own pending audit record?)
|
||||||
|
that must not exist for confidential data. The simplicity of "refuse" is itself the assurance
|
||||||
|
asset — trivially true, nothing to poke holes in.
|
||||||
|
- **Internal / public + central sink unreachable → degrade-and-warn** with a durable local buffer
|
||||||
|
+ ntfy alert + reconcile-on-recovery (the earlier Q4 design, now scoped to lower tiers). Keeps
|
||||||
|
capture available for your own homelab work during an observability outage; negligible risk
|
||||||
|
since the buffered record is still durable and the data isn't client-confidential.
|
||||||
|
- **Floor (all tiers):** if *nothing* — neither central sink nor (for internal/public) the local
|
||||||
|
buffer — can record the audit, capture **refuses**. No tier writes wholly un-audited.
|
||||||
|
- Rationale: matches assurance cost to data sensitivity, exactly as the I1/sovereignty model
|
||||||
|
does for placement. Presentable to a due-diligence client as "audit posture is
|
||||||
|
classification-aware: confidential fails closed, internal degrades gracefully" — which
|
||||||
|
demonstrates the judgment, not just a binary. Couples Q4 to Q1's classification machinery
|
||||||
|
(being built anyway) and removes the buffer-integrity rabbit hole for the only tier where it
|
||||||
|
mattered.
|
||||||
Reference in New Issue
Block a user