Two call sites, one helper function, the same three-line pattern for recovering an authenticated actor from a context.Context. Copy the pattern from the place it already works, apply it to the place it's missing, done — except the second call site silently never worked, and the only way to find that out was to write the test the plan called for and watch it fail.
The gap that looked like a missing feature
App.Provenance() records who did what to a piece of content — but its ActorKind field only ever said "human" or "". Meanwhile, the relation graph's own provenance recording (relations.go) already distinguished a job-driven relation assertion from a human one, via a field (RelationEdge.CreatedByJob) set directly on the data being written. Same underlying capability, two call sites, one of them working and one not.
The obvious fix: give SignalEvent — the payload every lifecycle-transition subscriber receives — a way to carry the same signal. But there's no equivalent "edge" to attach a field to on a lifecycle transition; the actual identity information already lives on the authenticated ctx.User(). And relations.go already has a working pattern for recovering exactly that from a bare context.Context: a type assertion, ctx.(Context), that succeeds because smeldr.Context embeds context.Context and Go interfaces keep their full concrete method set when passed through an interface-typed parameter.
So: copy the pattern. Add the same type assertion inside App.Provenance()'s signal handler. It compiles. It reads correctly. It matches an established, already-reviewed precedent in the same codebase.
Why it doesn't work anyway
App.Provenance()'s handler isn't called the way relations.go's is. relations.go calls its provenance-recording function directly, inline, synchronously, from the same function that just wrote the database row — the ctx it receives is the exact one the original caller passed in. App.Provenance()'s handler, on the other hand, runs through the signal bus: dispatchBus wraps the incoming context in context.WithoutCancel, then context.WithTimeout, before calling each subscriber. Both are completely ordinary standard-library context wrappers — and both return their own private struct types that embed the parent context.Context but implement nothing beyond it. The rich smeldr.Context interface — User() and everything else — doesn't survive the wrap. ctx.(Context) inside the handler silently returns ok == false, every time, in production, forever.
Two call sites that use the identical three lines of code. One of them crosses an async dispatch boundary that strips the very thing those three lines depend on. The only way to have caught this before shipping was to write the test the plan said to write, and run it, before writing anything else: a job-tagged actor triggering a real lifecycle transition, asserting that the resulting ActorKind came back "job". It came back "human". That one failing assertion was worth more than any amount of re-reading the two code paths side by side.
The fix moves the capture point earlier, to somewhere the wrapping can't reach: buildSignalEvent, which already runs synchronously, before dispatch, to build ActorID and ActorRole from the same ctx.User() call. A new field, SignalEvent.ActorRoles, captured right there, read directly in the handler — no recovery attempt needed at dispatch time at all. Every existing hand-built SignalEvent{} test literal kept passing without a single edit; a nil ActorRoles behaves exactly like today's default.
A second thing the same test surfaced
Wiring App.Provenance() into a real running instance — the other half of this change — needed a test that actually exercised the full path a real server uses: build the app, start an HTTP server, create something over MCP, check that a record landed. The test found zero records. Not a wiring bug in Provenance() this time — App.Handler() itself never wires the signal bus at all. Only the blocking App.Run() does. Any caller who embeds Smeldr's http.Handler in their own server — which is a documented, intended way to use it, not a workaround — gets a completely inert signal bus: no webhooks, no audit trail, no provenance, silently.
The fix looks small — call the same wiring function from Handler() too — but it isn't quite a one-line patch. The first attempt guarded it to run once, matching every other lazy one-time setup already in Handler(). That broke a different way: content types registered *after* that first call never got wired at all, because the guard fired before all of them existed. The right shape turned out to be the opposite of "once" — re-run it every time Handler() is called, since it's cheap and nothing about it can double-register anything.
Neither of these was found by reasoning about the code. Both were found by writing the test the task already called for, running it, and treating a failure as information rather than an obstacle to route around.