The question that started this one was simple: could a Decision be ratified with curl? The answer turned up a real gap on the way there. Smeldr's typed content types, the ones you define with smeldr.NewModule, only had PUT over REST. Send a body missing a field, and that field gets zeroed, because PUT is a full replace by definition. Dynamic content already had a proper PATCH route with real partial-update semantics. MCP already had the same, through MCPUpdate. Typed REST was the one surface that never got one.
The easy part: don't rebuild what already works
MCPUpdate already does exactly what a PATCH handler needs: merge the new fields onto the existing item, restore identity and status afterward so a caller can't smuggle either through, validate, save. The new route's whole job was supposed to be decoding an HTTP body into the map MCPUpdate already accepts, plus the role check a REST route needs that an MCP tool gets from a different layer. Call MCPUpdate, done.
Reading the code before calling it
That plan was correct in outline and wrong in one specific way, and the only way to catch it was reading MCPUpdate's own body rather than trusting its name. Buried in its final line: m.notifyAfter(ctx, AfterUpdate, ..., surfaceMCP, ...). Hardcoded. Every caller of MCPUpdate, regardless of how it got there, ends up recorded as an MCP-originated update.
That matters here because Smeldr has already done real, deliberate work threading Surface accurately through this codebase: fourteen separate call sites, each traced individually, each given the correct one of "http", "mcp", or "trigger". updateHandler, the existing PUT route, gets this right: it passes surfaceHTTP at the equivalent point. A new PATCH route that called MCPUpdate directly would have quietly undone that work for every partial update: every provenance record, every audit trail entry for a PATCH request would say "mcp" when the actual caller never touched MCP at all.
The fix, and proving it
The merge logic itself didn't need to change, only who gets to say which surface it happened on. MCPUpdate's body moved into a new function, updateFields, taking the surface as a parameter. MCPUpdate itself shrank to one line: call updateFields with surfaceMCP, exactly the value it always passed implicitly before. The new PATCH handler calls the same function with surfaceHTTP.
The easy failure mode here is writing the fix and trusting it by inspection. Instead: a test that wires a real provenance store, subscribes to the real signal bus, sends a real PATCH request through the real HTTP handler, and asserts on what actually got recorded. Not a check that the plumbing exists: a check that this specific route uses it correctly. It failed the first time it was worth running, before the fix, and passed after.
What stayed exactly the same
A PATCH body can't change an item's status any more than it could before. Since the new route inherits MCPUpdate's own restore-after-merge logic, a Status or ID field in the request body is silently discarded, identical to what MCPUpdate has always done. transition_item and PUT remain the only two ways to actually move an item's state. This wasn't a fresh decision for this task; it fell out of reusing the right logic rather than writing new logic that might have made a different, untested choice.