Problem class: go-consumer-side-sink-interface-composition-root-wiring-proof
The Python stub verified (21 lines, streams correct SSE, prints its port). Full runnable solution written to /workspace/solution.md. Here it is in condensed but directly usable form:
Problem class: go-consumer-side-sink-interface-composition-root-wiring-proof
Bug: tick-563 wiring-bypass — hook unit tests inject a fake sink, so they pass even when the real composition root never installs one. Production silently persists nothing.
Import cycle. The persistence service depends on the hooking package transitively:
service/runstore ─▶ internal/context ─▶ internal/card
▲ │
└──────── would form a cycle ──────────┘
So internal/card cannot import the service to hold a *runstore.Service. Developers add an optional sink, whose zero value is nil (inert). If the composition root forgets one line — hook.SetPersister(adapter) — the binary boots, serves, completes runs, and persists nothing, with no error. go test ./internal/card/... cannot see this because the test sets the sink itself.
Secondary defect: the service holds its mutex while calling the sink. The sink does I/O (possibly re-entering the service) → serialization or deadlock.
internal/card — plain-data consumer-side interface, optional field, setterpackage card
// Declared where consumed. Only plain data crosses it, so card imports
// nothing from the service layer.
type RunPersister interface {
PersistRun(runID, treeID, nodeID, status string, payload []byte) error
}
type Hook struct {
mu sync.Mutex
log *slog.Logger
persister RunPersister // optional; nil => byte-identical inert behavior
}
func (h *Hook) SetPersister(p RunPersister) { h.persister = p }
func (h *Hook) OnRunComplete(r RunResult) {
h.mu.Lock()
runID, treeID, nodeID, status := r.ID, r.TreeID, r.NodeID, string(r.Status)
payload := append([]byte(nil), r.Payload...) // copy; do not retain
h.mu.Unlock() // released before any I/O
if h.persister == nil {
return // inert path identical to pre-fix behavior
}
if err := h.persister.PersistRun(runID, treeID, nodeID, status, payload); err != nil {
h.log.Warn("card: persist run failed", "run_id", runID, "err", err)
// NEVER flip terminal run status on sink error
}
}
Apply the same discipline to any Service method that triggers persistence: unlock its mu before the sink call.
// cmd/canopy/adapter.go
var _ card.RunPersister = (*runPersistAdapter)(nil) // compile-time proof
type runPersistAdapter struct {
store *runstore.Service
log *slog.Logger
}
func (a *runPersistAdapter) PersistRun(runID, treeID, nodeID, status string, payload []byte) error {
return a.store.SaveRunArtifact(runstore.Artifact{
RunID: runID, TreeID: treeID, NodeID: nodeID, Status: status, Payload: payload,
})
}
// cmd/canopy/wire_sink.go
//go:build !unwired
func wireSink(h *card.Hook, s *runstore.Service, l *slog.Logger) {
h.SetPersister(&runPersistAdapter{store: s, log: l}) // ← the tick-563 line
}
// cmd/canopy/wire_sink_unwired.go (negative-control build only)
//go:build unwired
func wireSink(h *card.Hook, s *runstore.Service, l *slog.Logger) {}
Call wireSink(hook, runStore, log) exactly once before serving.
Unit tests that set the sink themselves can never catch an unwired root. Add a leg that builds and boots the actual cmd/canopy binary:
go build -o $TMP/canopy ./cmd/canopyEnv (env -i semantics): JWT_SECRET, PORT, DB_PATH, UPSTREAM_BASE_URL, CANOPY_ENV=test — nothing inherited.GET /health 200.POST /trees with {"title": "...", "rootMessage": {"role":"user","content":"go"}}.GET /trees/{id}/nodes (flat GET /api/v1/nodes/{id} is 405).POST /trees/{id}/nodes/{rootNode}/run, poll nodes, assert metadata.runId/artifact appears.go test -tags integration -run TestCompositionRootPersistsRun ./test/wiring/ -v
# NEGATIVE CONTROL — must FAIL, proving the leg detects tick-563:
CANOPY_BUILD_TAGS=unwired go test -tags integration -run TestCompositionRootPersistsRun ./test/wiring/ -v
| Gate | Trap |
|---|---|
| health = 200 | JWT_SECRET, not CANOPY_JWT_SECRET; empty secret = silent boot death |
POST /trees |
must include title and rootMessage object |
GET /trees/{id}/nodes |
flat GET /api/v1/nodes/{id} returns 405 |
POST …/run then poll |
drives the real route, not a direct unit call |
metadata artifact |
proves the composition root called the sink |
-tags unwired fails |
proves the test is not a tautology |
-race on card/service |
catches sink-still-under-mutex / deadlock |
Full 567-line document with the complete Go integration test, adapter files, checklist, and race checks is at /workspace/solution.md.
# Evidence - Problem class: go-consumer-side-sink-interface-composition-root-wiring-proof - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-26T06:04:55.871Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "When a hook must persist data through a service owned by the composition root but the hooking package cannot import the service package (import cycle: service->context->card->service), define a plain-data consumer-side interface in the hooking package (optional field + setter, nil = byte-identical inert behavior) and implement the adapter at the composition root. Proof obligation: a worker's unit tests that SET the interface themselves can never catch an unwired composition root (the tick-563 wiring-bypass class) - add a real-binary isolated-boot leg: build the actual cmd binary, boot with env -i against a throwaway DB + spare port + a stub upstream (here: a 30-line python SSE gateway), drive the real HTTP route, and assert the persisted artifact through the real read APIs. Traps on this stack: the JWT env var is JWT_SECRET not CANOPY_JWT_SECRET (empty secret = silent boot death, health never comes up); POST /trees needs title + rootMessage object; flat GET /api/v1/nodes/{id} is 405 - read node metadata via GET /trees/{id}/nodes; the sink call must run AFTER releasing the service mutex (collect inputs under lock, call after unlock, tolerate+log errors without flipping terminal run status).", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-consumer-side-sink-interface-composition-root-wiring-proof", "provider": "openrouter", "solved_at": "2026-09-26T06:04:55.871Z", "version": ""}