Compare commits

...
Author SHA1 Message Date
mathiasandClaude Opus 4.8 5288554338 fix(gitea): WriteFile creates via POST, updates via PUT (real contents API)
CI / Lint / Test / Vet (pull_request) Successful in 13s
CI / Mirror to GitHub (pull_request) Has been skipped
A live token-scope probe against mathias/ai-sessions revealed gitea's
contents API uses POST to create and PUT (sha required) to update — the
first impl always PUT'd, so creating a new summary 422'd "[SHA]: Required".
The httptest mock had the same wrong assumption. Pick the method by
whether the file exists (GET sha). Token confirmed contents:write
(push:true) by the probe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:22:37 +02:00
mathiasandClaude Opus 4.8 2368564523 fix(capture): wire ai-sessions SummaryWriter into the relay (#66)
CI / Lint / Test / Vet (pull_request) Successful in 13s
CI / Mirror to GitHub (pull_request) Has been skipped
The deployed CaptureService was constructed with a nil SummaryWriter, so
a capture carrying a summary block returned the partial-failure
"no summary writer configured" (surfaced in the 2026-06-23 claude.ai
dogfood). The Gitea client already reaches mathias/* over BRAIN_GITEA_TOKEN
and now implements SummaryWriter, so inject it (type-asserted from the
tracker) — no new credential, no manifest change. Session summaries now
write to mathias/ai-sessions at summaries/<harness>/<YYYY-MM>/...

Reuses the existing token deliberately; assumes it carries contents:write
scope on ai-sessions (it already does issue writes for the tracker). If
the token is issue-scoped only, the live write 422s — a token-scope widen,
not a code fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:47:00 +02:00
mathiasandClaude Opus 4.8 0e28b2125b feat(gitea): WriteFile contents-API upsert — satisfies SummaryWriter (#66)
Adds Client.WriteFile (Gitea contents API) so the Gitea client also
implements capture.SummaryWriter. Upserts: a GET resolves the current
blob sha so an existing file is updated (the richer-fidelity-supersedes
rule for re-captured sessions) rather than 422'd. Owner stays the fixed
const; token only in the Authorization header (no leak — regression
tested). Refactors the HTTP path into a shared request() helper so the
contents flow can branch on 404 without it being an error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:47:00 +02:00
mathiasandClaude Opus 4.8 06e21c019e docs(capture): as-built implementation report for the #49 epic
CI / Lint / Test / Vet (push) Successful in 12s
CI / Mirror to GitHub (push) Successful in 4s
Records what shipped (v0.11.0): architecture, sub-issue→PR map, I1–I5
compliance, the operational env reference, test coverage, and the
deferred follow-ups. Companion to specs/capture-bdd-spec.md (the design
contract) for onboarding + future audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 07:41:24 +02:00
mathias 76514215f4 Merge pull request 'feat: capture relay — MCP capture tool for non-library harnesses (#55, capture 49f)' (#61) from feat/capture-mcp-relay into main
CI / Lint / Test / Vet (push) Successful in 12s
CI / Mirror to GitHub (push) Has been skipped
2026-06-23 05:26:55 +00:00
4 changed files with 278 additions and 23 deletions
+5 -1
View File
@@ -410,8 +410,12 @@ func main() {
os.Exit(1)
}
auditSink := buildAuditSink(ctx, brainDir, logger)
// The Gitea client also satisfies SummaryWriter (#66): session
// summaries are written to mathias/ai-sessions over the same API
// token. nil only if a future tracker impl lacks file writes.
summaryWriter, _ := tracker.(capture.SummaryWriter)
captureSvc := capture.NewService(
mcpSrv.BrainStore(), tracker, nil, classCfg, auditSink)
mcpSrv.BrainStore(), tracker, summaryWriter, classCfg, auditSink)
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
resolver := capturehttp.NewOriginResolver(sovereign)
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
+91 -22
View File
@@ -12,6 +12,7 @@ package gitea
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -93,37 +94,105 @@ func (c *Client) CloseIssue(ctx context.Context, repo string, number int, commen
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))
// WriteFile creates or updates a file in repo at path via the Gitea
// contents API — the SummaryWriter port (#66). It upserts: a GET resolves
// the current blob sha (if any) so an existing file is updated rather than
// rejected (the richer-fidelity-supersedes rule for re-captured sessions).
// Owner is the fixed const, like every other call.
func (c *Client) WriteFile(ctx context.Context, repo, path, content string) error {
cpath := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path)
sha, err := c.fileSHA(ctx, cpath)
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)
payload := map[string]any{
"message": "capture: " + path,
"content": base64.StdEncoding.EncodeToString([]byte(content)),
}
// Gitea contents API: POST creates a new file, PUT updates an existing
// one (PUT requires the current sha). Pick by whether the file exists.
method := http.MethodPost
if sha != "" {
method = http.MethodPut
payload["sha"] = sha
}
status, body, err := c.request(ctx, method, cpath, payload)
if err != nil {
return fmt.Errorf("gitea %s %s: %w", method, path, err)
return err
}
defer func() { _ = resp.Body.Close() }()
if status < 200 || status >= 300 {
return fmt.Errorf("gitea %s %s: status %d: %s", method, cpath, status, strings.TrimSpace(string(body)))
}
return nil
}
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)))
// fileSHA returns the current blob sha for a contents path, or "" when the
// file does not exist (404). Any other non-2xx is an error.
func (c *Client) fileSHA(ctx context.Context, cpath string) (string, error) {
status, body, err := c.request(ctx, http.MethodGet, cpath, nil)
if err != nil {
return "", err
}
if out != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, out); err != nil {
if status == http.StatusNotFound {
return "", nil
}
if status < 200 || status >= 300 {
return "", fmt.Errorf("gitea GET %s: status %d: %s", cpath, status, strings.TrimSpace(string(body)))
}
var meta struct {
SHA string `json:"sha"`
}
if err := json.Unmarshal(body, &meta); err != nil {
return "", fmt.Errorf("gitea GET %s: decode: %w", cpath, err)
}
return meta.SHA, nil
}
// do performs a JSON request against the Gitea API and decodes a 2xx
// 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 {
status, body, err := c.request(ctx, method, path, payload)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("gitea %s %s: status %d: %s", method, path, status, strings.TrimSpace(string(body)))
}
if out != nil && len(body) > 0 {
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("gitea %s %s: decode response: %w", method, path, err)
}
}
return nil
}
// request is the shared HTTP path: marshals an optional JSON payload,
// attaches auth (token only ever in the header), and returns the status +
// body so callers can branch on status (e.g. 404) without it being an
// error. Never logs the token.
func (c *Client) request(ctx context.Context, method, path string, payload any) (int, []byte, error) {
var reader io.Reader
if payload != nil {
reqBody, err := json.Marshal(payload)
if err != nil {
return 0, nil, fmt.Errorf("marshal request: %w", err)
}
reader = bytes.NewReader(reqBody)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return 0, nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "token "+c.token)
resp, err := c.http.Do(req)
if err != nil {
return 0, nil, fmt.Errorf("gitea %s %s: %w", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
return resp.StatusCode, body, nil
}
+66
View File
@@ -115,3 +115,69 @@ func TestErrorPathDoesNotLeakToken(t *testing.T) {
assert.NotContains(t, err.Error(), testToken, "token must never appear in an error message")
assert.Contains(t, err.Error(), "500")
}
func TestWriteFileCreatesNewFile(t *testing.T) {
var getPath, postPath, postBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
getPath = r.URL.Path
w.WriteHeader(http.StatusNotFound) // file does not exist yet
case http.MethodPost: // gitea contents API: POST = create
postPath = r.URL.Path
b, _ := io.ReadAll(r.Body)
postBody = string(b)
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{"content": map[string]any{"html_url": "https://git/x"}})
default:
t.Errorf("create must POST, got %s", r.Method)
}
}))
defer srv.Close()
err := gitea.New(srv.URL, testToken).WriteFile(context.Background(),
"ai-sessions", "summaries/claude-code/2026-06/2026-06-23-x-abcd1234.md", "# Summary\n\nbody\n")
require.NoError(t, err)
assert.Equal(t, "/api/v1/repos/mathias/ai-sessions/contents/summaries/claude-code/2026-06/2026-06-23-x-abcd1234.md", getPath)
assert.Equal(t, getPath, postPath)
// base64 of the content, no sha on create.
assert.Contains(t, postBody, "IyBTdW1tYXJ5") // base64("# Summary")
assert.NotContains(t, postBody, `"sha"`)
}
func TestWriteFileUpdatesExisting(t *testing.T) {
var putBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{"sha": "deadbeef"})
case http.MethodPut:
b, _ := io.ReadAll(r.Body)
putBody = string(b)
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{"content": map[string]any{"html_url": "https://git/x"}})
}
}))
defer srv.Close()
err := gitea.New(srv.URL, testToken).WriteFile(context.Background(), "ai-sessions", "p/x.md", "new")
require.NoError(t, err)
assert.Contains(t, putBody, `"sha":"deadbeef"`, "existing file → update with sha")
}
func TestWriteFileErrorNoTokenLeak(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte("bad"))
}))
defer srv.Close()
err := gitea.New(srv.URL, testToken).WriteFile(context.Background(), "ai-sessions", "p/x.md", "x")
require.Error(t, err)
assert.NotContains(t, err.Error(), testToken)
assert.Contains(t, err.Error(), "422")
}
+116
View File
@@ -0,0 +1,116 @@
# Capture capability — implementation report (as-built)
**Status:** Shipped 2026-06-23, tagged `v0.11.0`. Epic hyperguild #49 (sub-issues #50#55) closed.
**Spec:** `specs/capture-bdd-spec.md` (the design contract this implements).
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1I5) + the I2 acceptance ledger entry in `infra/docs/security-baseline.md`.
This document records what was actually built, where it lives, how it maps to the spec, and what was deferred — for onboarding and future audit. It does not restate the design rationale (see the spec and the linked brain entries).
---
## 1. Outcome
One uniform capture capability — insights → brain, action items → Gitea tickets, optional summary → ai-sessions — reachable identically from every harness:
- **In-process / direct-REST harnesses** (Claude Code CLI, Agentsquad, claude.ai Code, headless): `POST /capture` on the brain server.
- **MCP-native harnesses** (claude.ai Chat/Cowork/Design, Crush, Pi, LLM Council): the `capture` MCP tool, reached over the existing `/mcp` OAuth connector.
Both doors call the **same** `CaptureService`; only the transport and credential assembly differ. The persistence behaviour (validation, classification, I1 gate, orchestration, I5 audit, partial receipt) is written once.
---
## 2. Architecture (as-built)
```
POST /capture (REST) capture MCP tool
capturehttp.Handler mcp.Server.brainCapture
\ /
\ (auth → principal → /
\ origin; decode) /
v v
capture.CaptureService (use-case, pure)
┌───────────────┬───────────────┬──────────────┬───────────────┐
BrainStore IssueTracker SummaryWriter ClassificationPolicy AuditSink
brainstore. gitea.Client (nil today) classification.Config audit.Degrading
Store (REST) Sink / SlogSink
│ │
api.WriteNote/UpdateNote/ReadNote (#45) LokiCentral + FileBuffer
+ wing index + auto-tunnel + graph re-index + NtfyNotifier + Reconcile
```
- **`internal/capture/`** — the use-case + ports + entities. Pure; no I/O. Owns validation (fail-closed), effective-classification resolution (stricter wins), the **I1 sovereignty gate**, best-effort orchestration, the **two-phase I5 audit** (Reserve before writes / Record after), and the partial-aware receipt.
- **`internal/brainstore/`** — concrete `BrainStore` wrapping the #45 `api` primitives + wiki upkeep (wing `_index`, auto-tunnel, graph re-index). The MCP `brain_write`/`brain_update`/`brain_get` handlers were re-pointed at it: one implementation, not two.
- **`internal/classification/`** — `public < internal < confidential` taxonomy + per-wing/repo tags from an optional `classification.yaml`; fail-safe to confidential.
- **`internal/gitea/`** — `IssueTracker` over the Gitea REST API; owner forced to `mathias`; token only in the Authorization header.
- **`internal/capturehttp/`** — the REST adapter + the shared `Authenticate` / `DecodeRequest` / `OriginResolver` (also used by the MCP tool).
- **`internal/audit/`** — `SlogSink` (default) and the `DegradingSink` (loki + durable `FileBuffer` + `NtfyNotifier` + `Reconcile`).
- **`internal/mcp/`** — the `capture` relay tool + principal threading (re-derives the caller's principal from the Bearer header the chassis middleware discards).
---
## 3. Sub-issue → PR map
| Sub | Issue | PR(s) | Delivered |
|-----|-------|-------|-----------|
| 49a | #50 | #56 | classification taxonomy + per-wing/repo tags (fail-safe to confidential) |
| 49b | #51 | #57 | `CaptureService` use-case + ports + entities; `BrainStore` extraction (MCP re-pointed) |
| 49c | #52 | #58 | Gitea `IssueTracker` (owner forced mathias; token never logged) |
| 49d | #53 | #59 | `POST /capture` REST + OAuth2 + I1 sovereignty gate (server-derived origin) |
| 49e | #54 | #60 | I5 audit path + classification-aware degradation (loki + buffer + reconcile) |
| 49f | #55 | #61, infra #151 (ledger), #152 (deploy) | MCP `capture` relay tool + I2 ledger + I3 deploy |
Predecessor: #45 (`brain_update`/`brain_get` verbs, PR #46) — the read-after-write contract capture reuses.
---
## 4. Invariant compliance
| Inv | How satisfied |
|-----|---------------|
| **I1** sovereign containment | Effective classification = stricter(caller-declared, target-derived #50). Origin is **server-derived from the authenticated principal**, never `context.harness`. Confidential + us-nexus origin → refused before any write; refusal audited. Asserted-vs-derived mismatch → security event. |
| **I2** deliberate acceptance | The relay's cross-harness reach is recorded in `infra/docs/security-baseline.md` with six containment properties + Revisit-if, **merged before relay code shipped** (infra #151). |
| **I3** GitOps reconcilability | Env + `gitea-api-token` ExternalSecret under `infra/k3s/apps/supervisor/`, Flux-reconciled; image bumped by CD. No untracked runtime. |
| **I5** auditability | Every capture emits a request-level audit record. Classification-aware degradation: confidential + sink-down → hard-refuse; internal/public + sink-down → durable local buffer + ntfy + reconcile-on-recovery; floor → refuse if nothing can record. |
---
## 5. Operational reference (env)
Set on the `ingestion` deployment (`infra/k3s/apps/supervisor/ingestion-deployment.yaml`):
| Env | Purpose | Notes |
|-----|---------|-------|
| `BRAIN_GITEA_URL` / `BRAIN_GITEA_TOKEN` | enables the `IssueTracker` → gates `/capture` + the MCP tool | token from 1P `DMABE_GITEA_API_TOKEN` via ESO; unset ⇒ capture disabled |
| `BRAIN_LOKI_URL` | activates the `DegradingSink` | unset ⇒ `SlogSink` (audit to stdout → alloy → loki; no refuse/buffer semantics) |
| `BRAIN_NTFY_URL` / `BRAIN_NTFY_TOKEN` | degraded-state alerts | optional |
| `BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS` | JWT subjects treated as sovereign-soil | comma-separated; static-token caller is always sovereign; unknown JWT ⇒ us-nexus (fail safe) |
| `BRAIN_AUDIT_RECONCILE_INTERVAL` | buffer→loki replay tick | default 60s |
The audit buffer lives at `<brain>/.audit-buffer/capture.jsonl` on the brain hostPath (nodeSelector-pinned to koala) — durable across restart without a separate PV.
---
## 6. Tests
67 test functions across the six packages. Coverage maps to the spec's Gherkin: happy path, supersede-not-duplicate, fail-closed validation, partial-failure receipt, dry-run, stricter-classification-wins, I1 confidential-via-us-nexus-refused / via-sovereign-allowed / asserted-label-ignored / caller-cannot-forge-origin, I5 confidential-refuse / internal-buffer / floor-refuse / reconcile / buffer-survives-restart, and the MCP relay tool (forwards, preserves principal, unauth rejected). `task check` green.
---
## 7. Deferred (not in this epic)
Tracked here so they aren't lost; file as issues when picked up:
- **SKILL veneer** — the `close-session` SKILL becomes the claude.ai trigger/harvest layer that calls capture.
- **Per-harness token provisioning** for Crush / Pi / LLM Council (claude.ai is done via the existing `/mcp` connector).
- **Harvest adapters** — transcript-parse vs chat-memory-reconstruct vs agent-runlog, each assembling capture args at its own fidelity.
- **`SummaryWriter` impl** — ai-sessions summary persistence (the port + path logic exist; the concrete writer is nil today, so a request with a summary fails that one item).
---
## 8. Brain learnings
- `wiki/hyperguild/decisions/capture-classification-taxonomy`
- `wiki/hyperguild/decisions/gate-on-server-derived-signals-fail-safe`
- `wiki/hyperguild/decisions/two-phase-reserve-record-audit-gate`
- `wiki/hyperguild/failures/mcp-bearer-middleware-discards-principal`
- `wiki/hyperguild/facts/brain-mcp-embeddings-out-of-band-sync` (from #45, the predecessor)