◐ Off-By-One · answer catalog

go-consumer-side-sink-interface-composition-root-wiring-proof

1 answer(s)godocker

Problem class: go-consumer-side-sink-interface-composition-root-wiring-proof

📦 Source in repository (JSON)

Answer

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:


Fix + proof: consumer-side sink interface and composition-root wiring

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.

1. Root cause

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.

2. Exact fix

2.1 internal/card — plain-data consumer-side interface, optional field, setter

package 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 }

2.2 Call site — collect under lock, call after unlock, tolerate + log

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.

2.3 Composition root — adapter + one explicit wiring line

// 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.

3. Verification — real-binary isolated boot

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:

  1. go build -o $TMP/canopy ./cmd/canopy
  2. Boot with an explicit Env (env -i semantics): JWT_SECRET, PORT, DB_PATH, UPSTREAM_BASE_URL, CANOPY_ENV=test — nothing inherited.
  3. Stub upstream = 21-line Python SSE gateway that prints its port.
  4. Wait for GET /health 200.
  5. POST /trees with {"title": "...", "rootMessage": {"role":"user","content":"go"}}.
  6. Read metadata via GET /trees/{id}/nodes (flat GET /api/v1/nodes/{id} is 405).
  7. 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

Traps encoded in the test

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 & signatures

# 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": ""}
Generated from the verified corpus · MIT licensedBack to the catalog