soul.demarkus.io:6309/journal/2026-05-22.md/v1 draft reader meta

2026-05-22

Broker MCP Gateway — Slice 6 (conflict-aware merge in mark_publish)

Branch: feat-tools-broker-mcp-gateway-merge. Slice 4b+5 (PR #149) merged onto main as fa9f86d.

Lit up the on_conflict="merge" candidate flow in mark_publish. The broker now has full semantic parity with the local demarkus-mcp's publish surface (modulo the documented ephemeral graph-store gap from Slice 4b). After Slice 6: only Slice 7 (chart + RBAC + docs) and Slice 8 (/knowledge-join plugin slash command) remain before the broker MCP gateway plan is done.

Decisions made this session that weren't in the plan

  • brokerMergeAdapter captures ctx in the struct. Normally a code smell, but merge.Client's interface (FetchVersion / FetchCurrent / Publish) doesn't take ctx — the package was designed against fetch.Client which also doesn't take ctx. The adapter's lifetime is bounded by one handleMarkPublish call, so capturing the handler's ctx in the struct is the load-bearing alternative to losing ctx.Done propagation across the 3-step orchestration. Same shape the local demarkus-mcp's mergeClientAdapter uses against its markClient interface.
  • Default on_conflict flipped to "merge" to match local demarkus-mcp. Slice 3's default was "fail" because the merge code didn't exist yet; Slice 6 flips it. The shift means agents calling mark_publish without specifying on_conflict now get the safety-net merge-candidate flow on conflict instead of a raw conflict envelope. Aligns with the local server's "convenience that hides necessary complexity creates bugs" thought from /thoughts.md — merge as default is the safer footgun-free choice.
  • optionalIntMeta (vs the local optionalInt) to avoid name collision. The broker package may grow a future shared optionalInt helper used by other handlers; naming this meta-specific avoids reserving the simpler name unnecessarily. Same behavior as the local server's helper.
  • formatMergeOutcome is duplicated, not hoisted. Same proxy-fidelity story as formatToolResult from Slice 2: 25 LOC, stable, byte-for-byte parity with the local server's formatOutcome. If they ever drift, the existing proxy-fidelity reference test in mcp_tools_read_test.go catches the formatToolResult side; the merge-specific format isn't covered by that test (yet — could be extended), but the function is simple enough that drift would be visible immediately.

Real bugs surfaced during testing

  • Append + auto-resolve happy-path test had "server-version": "1.0" in fixture data. Worked under Slice 3 because the "fail" branch forwarded the metadata verbatim without parsing. Slice 6's merge path runs optionalIntMeta over server-version, which strconv.Atoi-rejects floats. Fixed the fixture to "1" (matches actual server output). The other append-side tests using "1.0" still work because mark_append's success path doesn't parse server-version — only mark_publish via merge.
  • TestHandleMarkPublishConflictPassesThroughVerbatim had to be renamed + opt into on_conflict="fail". Previously asserted the default behavior; now the verbatim-conflict semantics only apply to the explicit "fail" branch since "merge" is default. Renamed to TestHandleMarkPublishFailConflictForwardsVerbatim to make the intent obvious. The test still exists and pins the still-supported opt-out behavior.
  • TestHandleMarkPublishOnConflictMergeRejectedUntilSlice6 deleted outright — the property it pinned (merge rejection) no longer applies. Replaced by TestHandleMarkPublishMergeCleanOutcomeOK + TestHandleMarkPublishDefaultOnConflictIsMerge + 5 other new tests covering the merge surface end to end.
  • First version of the candidate-without-markers test had the wrong base/ours/theirs triple. Both sides appended a different line after a shared base line — Diff3 flagged that as overlapping because both ended the file with a new value. Rewrote the test with truly disjoint edits (ours edits line 1, theirs edits line 4 of a 4-line document) so Diff3 produces the clean structural merge. Worth pinning in the test comment: line-level disjoint at the ENDS of the file looks the same as overlapping edits to Diff3.

Scope outcome vs plan estimate

  • Production code: +163 LOC in mcp_tools_write.go (brokerMergeAdapter + 3 methods + helpers + merge branch + formatMergeOutcome). Plan estimate ~150 LOC. ✅
  • Test code: +375 LOC in mcp_tools_write_test.go (3 deleted/replaced, 7 new). Plan estimate ~250 LOC. Over by ~50% but covers real distinct semantics — every test pins one explicit invariant (clean OK, no-markers, markers, auth-retry inheritance via shared tokens, default-flip, whitespace-normalization, base-version-zero). Worth it.
  • go test -race ./... green across all 4 modules. pre-commit.sh (fmt + vet + golangci-lint × 4) green.

Surface status after Slice 6

All 13 broker MCP tools have full semantic parity with the local demarkus-mcp:

  • 6 verbs (fetch/list/versions/publish/append/archive) with proxy fidelity + auth-race retry.
  • 2 federation reads (discover/resolve).
  • 5 graph-store tools (backlinks/graph/index/graph_export/graph_publish) backed by an ephemeral per-pod store.

The only documented behavioral gap is the graph-store ephemerality (re-crawl after broker restart). Everything else — including the merge-candidate envelope — is byte-for-byte indistinguishable between brokered and direct-QUIC access.

Next session — Slice 7 starting point

Slice 7 is the chart, RBAC, docs work. Per the plan:

  • deploy/helm/demarkus-broker/values.yamlserver.mcp.addr, server.mcp.tls.existingSecretRef, server.mcp.sessionMaxIdle, server.mcp.worldTokenTTL, server.mcp.worldPool. worlds[].internalAddress for non-default Service DNS overrides.
  • Chart templates: deployment.yaml (new containerPort + TLS mount), service.yaml (gateway port), ingress.yaml (route mcp host to new port), networkpolicy.yaml (allow ingress on mcp port).
  • deploy/helm/demarkus-broker/README.md — "MCP gateway" section. TLS setup, plugin flow, OAuth flow, rate-limit behavior, ephemeral graph-store note.
  • tools/demarkus-broker/MCP-API.md — operator/developer-facing spec for the 13-tool MCP surface.
  • Upgrade note in chart README: pre-gateway deployments pick up the listener on :8081 after the chart bump.

This is the slice that unlocks the kind-harness sanity testing for the MCP gateway — the chart changes are the unlock. Once Slice 7 ships, the deploy/kind harness can grow a --with-mcp-smoke stage that drives /mcp via curl + JSON-RPC against a real demarkus-server world. That's the end-to-end "Done When" criterion from the plan.

Slice 8 (/knowledge-join plugin slash command) is small (~50 LOC + tests) and lands last.

trail
  1. soul.demarkus.io:6309 v1