The capture-routing in 723dab5 (#62 minimal) carried pre-#67 prose: it
warned "no populated classification.yaml yet" and steered repos_touched
away from brain/ai-sessions as if they'd escalate to confidential. #67
tagged the homelab repos internal, so that guidance is now wrong and
over-restrictive — listing the central repos is fine, and the summary's
fixed ai-sessions target no longer escalates the call. Point at
classification.yaml as the source of truth; reserve the refusal warning
for client-*/untagged material. Completes #62's veneer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
Collapse Phases 4 (summary commit) + 5 (brain note) into a single
capture-driven Phase 4. The skill now assembles one brain:capture payload
(insights + tickets + summary + context) and dry-runs-then-executes;
capture owns the brain/gitea/ai-sessions writes, the I1 gate, the I5
audit record, and the supersession/read-after-write discipline server-side.
Adds the classification-gate lesson learned dogfooding capture this
session: effective classification = strictest across ALL targets
(wings, ticket repos, repos_touched); untagged repos (brain, ai-sessions)
fail-safe to confidential and get refused via claude.ai, so repos_touched
must stay to internal-default repos. Legacy inline path kept only as the
capture-unreachable fallback. Staleness prose removed (server owns it now).
Partial of #62; harness-token + harvest-adapter + fidelity-supersession
work remain.
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>
Adds the `capture` MCP tool: the #55 relay for harnesses that cannot run
the use-case in-process (claude.ai Chat/Cowork/Design, Crush, Pi, LLM
Council). They reach it through the existing /mcp OAuth connector.
- Thin: forwards to the SAME CaptureService as POST /capture; holds no
state and retains nothing beyond the I5 audit record. The containment
properties accepted in infra security-baseline (I2 ledger) hold by
construction.
- Per-principal: ServeHTTP re-derives the caller's principal from the
Bearer header (the chassis middleware gates but discards it) and stashes
it in context; the tool resolves the trust-zone origin from it. A
caller-asserted harness/origin in the body is ignored — origin is
server-derived, so the I1 confidential refusal still fires for us-nexus
callers (claude.ai), and sovereign-allowlisted JWT principals pass.
- Registered only when WithCapture is wired (all three sites: tools(),
handleCall, package doc); main wires REST + MCP from the same service,
resolver, and credentials.
Tests: listed-only-when-wired, forwards-via-static-principal,
confidential-via-us-nexus-refused, confidential-via-sovereign-allowed,
unauthenticated-rejected, caller-cannot-forge-origin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lifts the Bearer principal-derivation and the request→CaptureInput decode
out of the REST handler into exported package funcs, so the MCP capture
tool (#55 relay) reuses the exact same auth precedence and wire shape —
one implementation, not two. No behaviour change to POST /capture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Map capture.ErrAuditUnavailable → HTTP 503 (audit substrate down /
confidential unauditable / floor).
- main: buildAuditSink selects the DegradingSink (loki central + durable
file buffer under brainDir + optional ntfy) when BRAIN_LOKI_URL is set
and starts the reconcile loop; else the plain slog sink. Notifier kept
as a nil interface (not typed-nil) when unconfigured so the sink and
reconcile skip it cleanly.
Env: BRAIN_LOKI_URL, BRAIN_NTFY_URL, BRAIN_NTFY_TOKEN,
BRAIN_AUDIT_RECONCILE_INTERVAL (default 60s). Buffer at
<brain>/.audit-buffer/capture.jsonl.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The classification-aware I5 audit path (§4.4):
- DegradingSink.Reserve: central up → AuditCentral; central down +
confidential → refuse (no buffer); central down + internal/public +
buffer writable → AuditBuffered; central down + buffer unwritable →
floor refuse. Record executes the reserved outcome and, when buffered,
fires an ntfy alert.
- FileBuffer: durable JSONL buffer that survives process restart; Confirm
rewrites the file without a record, so a buffered record is cleared ONLY
after its central write is confirmed.
- LokiCentral: /ready probe + /loki/api/v1/push (full audit entry as the
structured line). NtfyNotifier: degraded-state alerts; token only in the
auth header, never logged (regression-tested).
- Reconcile + StartReconcile: replay buffered records to central on
recovery, confirm-then-clear per record; a failed push keeps the record
buffered (no loss). SlogSink updated to the two-phase shape (always
central, never fails) — the default when no loki endpoint is set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Splits the audit port into Reserve (before any write) + Record (after),
so "confidential + sink-down → refuse before any write" is literally true
even though the audit record — which lists what landed — can only be
written afterwards.
- AuditSink.Reserve(ctx, level) → AuditOutcome | error. The error path
refuses the capture before writing: confidential + central sink down,
or the all-tiers floor (nothing can record).
- AuditSink.Record(ctx, entry, outcome) persists per the reserved outcome.
- Service: I5 gate runs after the I1 gate and after the dry-run
short-circuit (dry-run never probes the sink). AuditBuffered surfaces on
the receipt. New ErrAuditUnavailable sentinel (→ HTTP 503).
The tier→behaviour decision lives in the sink impl (#54's DegradingSink),
not the service — the service just honours Reserve's verdict.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The HTTP door for the capture capability. Thin: authenticate → derive
trust-zone origin → decode → capture.Service → map receipt to status.
- Auth mirrors the chassis Bearer precedence (static token wins, then Dex
JWT) but returns the resolved principal + auth path, which the chassis
middleware hides — capture needs the principal to derive the origin.
Depends on a small Validator interface (the chassis *JWTValidator
satisfies it) so the JWT/origin path is testable without a live JWKS.
- OriginResolver maps principal → trust zone: static-token caller and
allowlisted JWT subjects → sovereign; every other principal → us-nexus
(fail safe, so the I1 gate refuses confidential by default). Principal
and origin are server-set on the input, overwriting any body the caller
sent.
- HTTP status: 200 all-ok / dry-run, 207 partial, 502 all-failed, 403 on
the I1 refusal, 400 on fail-closed validation.
- Wired in main behind the same static+JWT credentials as /mcp, reusing
the MCP server's graph-wired brain store (one implementation) and the
classification tags (#50). Mounts only when a Gitea tracker is
configured. Sovereign JWT principals via BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Emits the request-level audit record to structured logs (scraped by the
existing alloy/loki substrate) and surfaces security events at warn
level. Placeholder behind the AuditSink interface — the classification-
aware loki+buffer+reconcile sink (confidential fails closed, internal
degrades) lands in #54 and replaces this without touching callers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the trust-zone Origin to CaptureContext and the I1 gate to the
use-case: a confidential effective classification through a us-nexus
origin is refused before ANY write (ErrSovereigntyRefused), and the
refusal is itself audited. A caller-asserted harness label that names a
different zone than the server-derived origin is logged as a security
event — context.Harness is descriptive-only, never a gate input.
The gate triggers only on an explicit ZoneUSNexus, so the unset default
(ZoneUnknown) can never make it fire on caller-controllable input; the
REST adapter always sets a concrete zone.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the IssueTracker port as a real Gitea REST client (#49c) — the
new outbound dependency the brain server gains for capture.
- CreateIssue / CommentIssue / CloseIssue(+optional closing comment) over
the Gitea API. Owner is the const "mathias", never caller-supplied, so
a caller cannot redirect a write to another owner's repo.
- Token read once at construction (BRAIN_GITEA_TOKEN), held in the struct,
travels only in the Authorization header — never logged or in argv.
Error messages carry status + truncated body, never the token
(regression-tested). gitea.New returns nil when URL or token is unset,
so missing config = tracker disabled via one nil check.
- Injected into the MCP server behind the capture.IssueTracker interface
via WithIssueTracker (constructor injection, swappable/testable); main
wires it from BRAIN_GITEA_URL (default https://git.d-ma.be) +
BRAIN_GITEA_TOKEN. Consumed by the capture use-case in #53.
Tests use httptest transports: create (owner+auth header asserted),
comment, close with/without comment, error path that proves the token
never leaks into an error string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#52's IssueTracker spec is CloseIssue(repo, number, comment). Refine the
#51 port signature to match and have the service pass the ticket body as
the closing comment (empty ⇒ close only). Keeps the close-with-comment
flow first-class rather than forcing two separate ticket items.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extracts the #45 write/update/get logic + the wiki upkeep that must
accompany a write (wing _index rebuild, cross-wing auto-tunnel, graph
re-index) into a single concrete brainstore.Store implementing
capture.BrainStore. The MCP brain_write/brain_update/brain_get handlers
are re-pointed at it, so there is one implementation, not two — the DRY
payoff #51 is named for. capture and MCP now share the exact same brain
write path and read-after-write contract.
The Server gains a *brainstore.Store, constructed in NewServer and given
the graph store in WithGraph. Embedding refresh stays out-of-band
(mtime-driven vectorstore.Sync), unchanged. Existing MCP brain_update/
brain_get/brain_write tests pass unmodified — behaviour and the
{id, path, content_hash} response contract are preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Clean-Architecture core of the capture capability (#49b). Pure
orchestration over ports — no HTTP, no live Gitea, no audit I/O — fully
unit-tested against fakes before any adapter exists.
- Ports: BrainStore (#45 write/update/get), IssueTracker, SummaryWriter,
ClassificationPolicy (satisfied by #50's classification.Config),
AuditSink. Entities: Insight, Ticket, Summary, CaptureContext,
CaptureInput, CaptureReceipt.
- CaptureService.Capture: validate-before-write (fail-closed), resolve
effective classification (stricter of declared vs target-derived;
under-declaration logged as a security event), orchestrate insights
(write/supersede) → tickets → summary best-effort, emit a request-level
audit record of exactly what landed, return a partial-aware receipt.
- dry_run short-circuits after validation, writes nothing (not even audit).
Out of scope here, layered on later: the I1 origin sovereignty gate (#53,
needs the server-derived principal) and the classification-aware audit
degradation/refusal (#54). "Effective" is folded into the service as
classification.Stricter rather than a port method — the stricter-wins
rule is use-case policy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the I1-prerequisite from #49/#50: the classification taxonomy
and the per-wing / per-repo tagging the capture server reads to derive a
target's sensitivity.
- Levels public < internal < confidential, ordered so "stricter wins"
(spec §4.1 model C) is a plain max via Stricter().
- Tags read from an optional classification.yaml at the brain root
(wings:/repos: maps). Absent file → defaults-only, not an error.
- Defaulting: client-* → confidential; hyperguild/homelab → internal;
everything else → confidential. Fail-safe-to-strictest is the
load-bearing property: a missing tag never silently downgrades.
- Config.Derive(Target) is the function the use-case calls; Wing/Repo
are the per-kind helpers. ParseLevel rejects unknown tokens; a bad
level in the config file is a hard load error.
Central classification.yaml (not _index.md frontmatter, not gitea repo
topics): classifying a repo needs no live Gitea client, so #50 has no
dependency on the tracker work (#52); it's auditable in one place; and
it avoids BuildWingIndex clobbering a wing's regenerated _index.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconsidered Q4: instead of one global degrade-and-warn, the posture now
inherits from effective classification (Q1):
- confidential + audit-sink-down -> hard-refuse (no buffer; removes the
buffer-integrity question for confidential data)
- internal/public + audit-sink-down -> degrade-and-warn + durable local
buffer + ntfy + reconcile-on-recovery
- floor (all tiers): refuse if nothing can record the audit
Updated the I5 Gherkin scenarios + obligations row to match. Couples Q4
to the Q1 classification spine -> one coherent sensitivity model.
Q1 classification trust: model (C) — caller declares, server cross-checks
target tag, stricter wins, mismatch logged. Needs a classification
taxonomy + per-wing/repo tags (prerequisite, sub-task of #49).
Q2 harness origin: server-derived from authenticated principal;
context.harness is descriptive-only, never a gate input.
Q3 central relay: ships in v1 (needed for claude.ai/Crush/Pi/LLM Council)
+ I2 security-baseline ledger entry is v1 work.
Q4 audit-sink-down: degrade-and-warn + durable local buffer + reconcile
on recovery; refuse only if NOTHING can record the audit.
Updated the I1 sovereignty + I5 auditability Gherkin scenarios to match;
added classification-mismatch and origin-spoofing scenarios.
Pre-build spec for the uniform cross-harness capture path. Grounds the
design in the homelab invariants (I1-I5, now canonical on infra main):
- I1 sovereignty gate: refuse confidential capture via us-nexus harness
- I2: distributed-library form (no high-degree observer node) is
admissible; central relay needs a security-baseline ledger entry
- I5: capture is a privileged write path, must emit audit records
Gherkin scenarios cover the happy path, the sovereignty refusal,
supersession + staleness discipline (#45/#47), fail-closed validation,
best-effort partial-failure receipts, dry-run, and fidelity supersession.
Four open questions flagged for review (classification trust is the
highest-risk one).
Disciplined end-of-session closeout for Claude.ai chats: harvest →
ground-truth gitea → confirm issue actions → commit session summary →
brain orientation note → safe-to-archive verdict.
Finalized against current infra (git.d-ma.be, koala:30401 LiteLLM,
Authentik) and the brain_update/brain_get verbs. Phase 5 uses
supersede-by-slug + brain_get read-after-write with the batch
no-semantic-query-after-supersede discipline. session_log/brain_tunnel
inlined (now callable from claude.ai). Summary frontmatter is the
reduced live-capture schema with fidelity:live-capture to distinguish
from batch-export summaries.
Drop the context:sync:cursor task and remove .cursorrules +
.aider.conventions.md from the drift-guard file list in `check`, now
that the generator no longer emits them.
Cursor and Aider are not supported harnesses. Remove generate_cursor
and generate_aider, their no-arg calls, and their case arms. The active
adapters are claude (CLAUDE.md), agents (AGENTS.md), and system-prompt
(.context/system-prompt.txt).
context-sync regenerated the adapters from the updated root AGENT.md
(rule 0 pre-task ritual + TDD constraint). The committed adapters had
drifted; this is the documented `task check` remedy, not a content
change in this repo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the #45 verbs into the MCP surface (all three sites: tools()
descriptors, handleCall dispatch, package doc comment).
- brain_update: supersede-by-slug or full path; rebuilds wing _index and
re-tunnels cross-wing matches against the new body (idempotent,
best-effort), re-indexes the graph, returns {id, path, content_hash,
superseded}.
- brain_get: fetch by id or path (both are the brain-relative handle);
returns {id, path, content_hash, frontmatter, body}.
- brain_write: return contract extended from {path} to {id, path,
content_hash} — path kept for backward compat — so the create path
also yields a stable handle.
id == relPath; content_hash == sha256 of the file bytes. Tests cover the
supersede happy path, missing-target error + no-create, get by id/path,
write handle, and an end-to-end re-embed test that drives the real
vectorstore.Sync re-index after an update.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the api-layer half of #45. UpdateNote supersedes a note in
place (whole-note body replace, frontmatter re-stamp: updated_at,
supersedes=prior content hash, supersede_reason), preserving created_at,
wing, hall, and any custom fields. Never creates — a missing target is
an error so callers fall back to brain_write. ReadNote is the read-after-
write primitive (frontmatter + body + content_hash). ContentHash is the
sha256 handle that round-trips write/update → get.
Frontmatter is edited via a line-preserving ordered editor rather than a
yaml.v3 round-trip, which would reorder keys and strip comments — the
brain writes flat key:value frontmatter by hand.
Embeddings are not refreshed here: the rewritten file's mtime advances,
which the out-of-band vectorstore.Sync ticker uses to re-embed it — the
same mechanism brain_write relies on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A doc with no headings and no blank-line paragraphs (JSON-lines, e.g.
wiki/telos/decisions/human-intent-column.md) survived both chunk passes whole
and was sent to nomic-embed over its context window → 'input length exceeds
the context length' (400, the steady embed errors=1). Add a final hard-split
pass (line then UTF-8 rune boundaries) so no chunk exceeds maxBytes. TDD.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gitea.d-ma.be→git.d-ma.be rename moved the infra deployment manifests, but
cd.yml still sed-patched 'gitea.d-ma.be/mathias/ingestion:' — which no longer
matches, so the patch produced no change and 'git commit' failed under set -e,
wedging all deploys (image stuck at e8dbcf6). Repoint the registry image refs,
the infra-patch sed patterns, and the rollout-verify EXPECTED values to
git.d-ma.be (same registry backend). SSH alias + INFRA_REPO left as-is.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Qwen3-Reranker is a web-search cross-encoder. Against conversational /
personal-intent queries (e.g. 'what am I optimizing toward?') it scores every
candidate as 'no', so brain_answer collapsed to 'No relevant content found'
even though BM25 had retrieved on-topic notes (incl. the telos wing). Treat
the reranker as a filter: when it keeps zero results, fall back to the
BM25/vector ordering (capped to the no-reranker depth of 10). Refs brain#11.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop-in unified findings doc for the brain-MCP intent study. Locks the
shared row schema + closed intent vocab both columns must conform to.
Agent column filled from agent-intent-column.jsonl (46 acts, 37%
mismatch); human column left as PENDING cells + <<SYNTH>> blocks so the
Claude.ai-history analysis merges in without re-deriving structure.
Pre-seeds the cross-consumer divergence questions: agent mismatch is
write-side-heavy (supersede + verify-landed); hypothesis is human
mismatch is read-side-heavy (semantic + answer) — interface may fail the
two consumers at opposite ends.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Agent-consumer column of the two-part brain intent↔interface study.
Reconstructs the knowledge-act behind every brain MCP call in the
Claude Code agent transcripts on koala (the only corpus with brain
calls; brain/sessions and agentsquad eval logs carry none).
46 distinct knowledge-acts, 37% interface mismatch, 0% intent_unclear.
Headline gap: no update/supersede verb → agents blind re-write same
slug (5x); no read-after-write → lexical re-query of own note (4x);
lexical-only reads → semantic-as-keyword-stuffing chains (3x);
brain_answer hedged with parallel brain_query.
Canonical schema brain-intent-extraction.md absent on host; vocab
reconstructed, every row tagged schema_source=reconstructed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A ~/.zshrc read surfaced OP_SERVICE_ACCOUNT_TOKEN into a transcript. The
_TOKEN= form was already caught by homelab-env-token, but a bare ops_<b64>
value was not. Add an op-service-account rule (ordered early). Tests cover
both env-assigned and bare forms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A shell mangle that glued a key to a preceding word ('yes'+'sk-...') had
no word boundary, so the leading \b in the openai-sk rule failed to match
and a LiteLLM master key leaked past the scrubber into ingest (2026-06-11).
- openai-sk: drop leading \b, match sk- shape anywhere ({32,} floor keeps
short task-/disk- words clean).
- add jwt rule for bare header.payload.sig tokens (no Bearer prefix).
- regression tests: the exact yessk- evasion, standalone sk-, bare JWT,
plus clean-content guards for task-/disk-.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>