esc
The Solid Convergence

The Plumber and the Pipe

The Solid Convergence, March 22, 2026 (in which a content editor was rebuilt from scratch using lessons from its own failure, five NATS pipes were replaced by one git pipe, seven language models were...

March 22, 2026

The Solid Convergence, March 22, 2026 (in which a content editor was rebuilt from scratch using lessons from its own failure, five NATS pipes were replaced by one git pipe, seven language models were tricked into streaming artifacts as text, a footer learned to count, and the Squirrel was denied a StatusBarOOBRendererRegistryFramework but permitted a structured BitLink)


Previously on The Solid Convergence…

The Gardener had sharpened his shears. The first attempt at a git-native content editor (S-521) had been composted — architecturally correct, topologically tangled. Three stones had been mapped. A lessons-learned file had been written. S-522 had moved the URL navigation stone.

The path was clear. The shears were sharp. The stones were gone.

Time to plant.


06:39 — The Ticket That Wrote Itself

riclib opened S-521’s three comments. Read them twice. Lessons from the first attempt, cherry-pick candidates, architecture decisions. Then he opened a new conversation.

riclib: “Read S-521.”

CLAUDE: reading the Linear ticket, all three comments

riclib: “Now let’s discuss architecture.”

Three hours of discussion. Not coding. Discussing. The kind of conversation the Squirrel hates because no code is produced and the kind the Lizard loves because every stone is placed before the first seed.

Three decisions emerged:

One: the JavaScript footer was fragile. The server already knew which fields changed. The footer should be server-driven, not a client-side DOM counter.

Two: the content editor needed to work for skills, prompts, artifacts, and domains that didn’t exist yet. A ContentEntity interface. Two methods. GetContent(). SetContent(). Done.

Three: the LLM tool call should write to disk, render an OOB swap, and push it through the tab session’s SSE channel. No NATS. The tab session was already there. In every request context. With a SendSSE method. Just waiting.

THE SQUIRREL: appearing from the third comment “We should build a StatusBarOOBRendererRegistryFramework that—”

riclib: “The status bar is going in the footer. With the other buttons.”

THE SQUIRREL: “But the footer is JavaScript! And the content field is server-rendered! They don’t—”

riclib:updateChangeSummary() counts .field--changed inside #record-editor instead of #record-form. One line change.”

THE SQUIRREL: counting on her fingers “That… actually works.”

riclib: “Write the ticket.”

And CLAUDE wrote S-526. The ticket that described itself into existence from the lessons of its own predecessor.


07:15 — The Port

Step one was mechanical. Port the pure functions from infra/mdedit to infra/contenteditor. DiffSections. AnnotateHTML. ReplaceSection. PatchLines. TableOfContents. The algorithmic bones of the old system, stripped of every NATS dependency, copied wholesale.

28 tests. All passing. Zero imports from NATS. Zero imports from liverepo.

The Squirrel watched the diff scroll past, waiting for the interesting part.

[A scroll descended. Brief. The Lizard had barely looked up.]

    THE FOUNDATION IS BORING
    BORING IS CORRECT
                            🦎

07:42 — The Editor

The Editor struct took shape in editor.go. Forty-two lines of API surface:

  • LoadProps — for initial page render
  • WriteAndNotify — for LLM tool calls
  • CommitAndNotify — for user save
  • DiscardAndNotify — for user revert
  • ReadContent — for the read_content tool

Each method: look up the source, do the operation, push an OOB swap through tab.SendSSE().

The old system’s flow: tool call → service EditorSave → NATS KV draft → EditorPublisher → NATS pub → EditorBridge → NATS sub → EditorHandler.RenderOOBTabRegistry.Broadcast.

The new system’s flow: tool call → editor.WriteAndNotifystore.WriteOnlytab.SendSSE.

The Squirrel counted the arrows. Five in the old. Two in the new.

THE SQUIRREL: “What happened to the other three?”

riclib: “Git.”


08:00 — The Wiring

The skill domain was first. SkillSource — six methods mapping to gitstore operations. GetContent() and SetContent() on the Skill struct — two one-liners. page.templ: replace @widgets.MdEditor(formData.EditorProps) with @contenteditor.ContentField(formData.ContentProps). The form footer’s updateChangeSummary() — changed #record-form to #record-editor.

Then the tool executors. EditContentExecutor — remove ContentEditor interface, remove EditorNotifier interface, take *contenteditor.Editor directly. ReadContentExecutor — same. One entry point. One editor. Routes by source type.

Then routes.go. Remove editorPub. Remove editorBridge. Remove twenty-six lines of NATS wiring. Add three lines of content editor registration.

Build. Test. All passing.

CLAUDE: “No NATS imports in the content editing path.”

riclib: “That’s the whole point.”


08:30 — The Test

riclib started the server. Opened a skill. The content rendered — markdown with headings, tables, code blocks. The footer said “No changes.” He asked the LLM to add a section. The content field updated via SSE. The footer said “1 field changed.” He asked the LLM to modify a section. The diff gutter appeared — green bars on changed headings. He asked the LLM to delete a section. The section vanished. The footer tracked every change.

He clicked Save. Git committed. The footer said “No changes.” He checked the file on disk. The content was there. He checked git log. The commit was there.

[The Squirrel was inspecting the working copy, looking for something wrong.]

THE SQUIRREL: “It’s… working?”

riclib: “It’s working.”

THE SQUIRREL: “The first attempt took four hours and needed a rollback.”

riclib: “The first attempt mapped the stones. This attempt walked on cleared ground.”

[A scroll descended. It was warm — the Lizard’s scroll equivalent of a handshake.]

    THE FIRST ATTEMPT WAS NOT WASTED
    THE FIRST ATTEMPT WAS THE MAP

    THE SECOND ATTEMPT WALKED THE MAP
    IN HALF THE TIME
    WITH NONE OF THE STONES

    THIS IS NOT ITERATION
    THIS IS MEMORY
                                        🦎

09:00 — Beta’s Sixteen Minutes

Then something happened that did not fit the mythology.

riclib dispatched a second Claude to a different machine. “Test whether language models follow <<<artifact>>> markers in their reply stream.” Seven models. Four test cases each. Twenty-eight trials.

Sixteen minutes later, beta reported back. 28/28 pass. 100% reliability. GPT-4o-mini. GPT-4o. GPT-5.2. Claude Sonnet 4.5. Claude Haiku. Gemini 2.5 Flash. Z.ai GLM 4.7. Every model followed the markers. Every model streamed artifact content as text, not as tool call arguments.

riclib stared at the results table for a long time.

riclib: “We don’t need the ArtifactStreamer.”

CLAUDE: “We don’t need the ArtifactStreamer.”

THE SQUIRREL: “We don’t need the—” pausing, doing mental arithmetic “That’s two hundred and eighty lines. Plus the streaming buffer. Plus the NATS bridge. Plus the publisher. Plus—”

riclib: “Plus the entire reason we couldn’t stream artifacts with Anthropic. Because Anthropic doesn’t stream tool calls. But every model streams text.”

THE SQUIRREL: very quietly “We moved the problem from the hardest layer to the easiest layer.”

riclib: “We moved the problem from the one layer that differs between providers to the one layer they all share.”


While alpha rested, beta kept designing. The question of how to store artifact references in the conversation stream — a deceptively simple question that touches audit, provenance, and context building.

The first proposal: embed structured data in the content string. [artifact:compliance-report "Compliance Report" 1,247 words commit:dc83e56].

Beta’s counter-proposal: structured Links on the Bit struct. Content stays clean with [ref:compliance-report]. Data lives in a typed BitLink — queryable, extensible, consistent with how every other cross-reference in the codebase works.

BETA: “Every other cross-bit reference uses structured fields. This would be the first time we parse data from free text.”

riclib: “And the git commit hash?”

BETA: “On the BitLink. CommitHash string. Full provenance. Who created it, when, what changed, which conversation triggered it.”

The Lizard said nothing. The Lizard doesn’t need to endorse things that are obviously correct.


09:45 — The Vision

And then riclib started talking about UX. Not architecture. Not code. UX.

“The artifact streams inline in the chat. The user sees it appearing — text, like any other response. When the server detects the marker, it opens the editor panel. Content starts flowing into the editor too. When the marker closes, the content in the chat gets replaced with a card. Title, word count, commit hash, a View button. The full content lives in git, not in the chat stream.”

“For Anthropic — where the content arrives as one block — the card appears when the block is done. Same result, different timing. The user doesn’t care about the plumbing.”

THE SQUIRREL: eyes wide “The streaming card could show a live token counter while—”

riclib: “Yes.”

THE SQUIRREL: “—and the editor could show progressively rendered markdown while—”

riclib: “Yes.”

THE SQUIRREL: “—and it works the SAME for EVERY model because it’s just TEXT—”

riclib: “Yes.”

[The Squirrel looked at her clipboard. On it, in her own handwriting from three months ago: “Multi-Provider Artifact Streaming Framework (v12).” She crossed it out. Wrote one word: “text.”]


The Tally

Systems replaced:                        5
  NATS KV drafts:                        gone (for content)
  Liverepo content path:                 gone
  EditorPublisher:                       gone
  EditorBridge:                          gone
  ArtifactStreamer:                       scheduled for demolition

Systems added:                           1
  infra/contenteditor:                   git + SSE

Lines in new package:                    1,406
Lines of NATS wiring removed:           268
Net:                                     cleaner

Files in contenteditor/:                 14
  Pure functions ported from mdedit:     5 (+ 3 test files)
  New:                                   6 (editor, handlers, types, props, templ x2)

Models tested for stream markers:        7
Tests run:                               28
Tests passed:                            28
Percentage:                              100

Time building the content editor:        2 hours
Time discussing architecture first:      1 hour
Time the first attempt took:             4 hours (composted)
Time the lessons saved:                  2 hours

Alpha sessions:                          1 (content editor, 2h)
Beta sessions:                           1 (marker POC, 16 min)
Total elapsed:                           ~2.5 hours
Total if done sequentially:              ~4 hours

Squirrel proposals denied:               2
  StatusBarOOBRendererRegistryFramework: "footer"
  Multi-Provider Artifact Streaming:     "text"
Squirrel proposals accepted:             1
  Live token counter during streaming:   "yes"

Lizard scrolls:                          3
Lizard eyes opened:                      1 (warm scroll)

Tickets created:                         7
  S-526: Content editor (done)
  S-527: Markdown editor exploration
  S-528: Artifact UI migration
  S-529: Prompt domain
  S-530: Dead code cleanup
  S-531: Stream marker POC (done, 16 min)
  S-532: Stream marker creation
Tickets killed:                          1 (S-521, composted)

The plumber does not admire the pipe.
The plumber admires the flow.

Five pipes, tangled, each correct,
each necessary in its time —
NATS for drafts, a bridge for broadcast,
a publisher for events, a streamer for chunks,
a handler for rendering —
each doing the right thing
in the wrong direction.

One pipe now.
Disk is the draft.
HEAD is committed.
Diff is the truth between them.
SSE carries the news.

The plumber kneels between the old and the new,
wrench in hand, connecting the clean copper
while the tangled brass still runs above.

Not ripping out. Not yet.
Phase 3 will do the ripping.
Phase 1 is the proof that the new pipe holds.

And somewhere, on seven different servers,
seven language models learned
to stream artifacts as text
because nobody asked them to do it differently
and the easiest layer is the one they all share.

The stones were moved.
The lessons were written.
The second attempt walked the map.

Git is the only state machine.

🦎


See also:

The Solid Convergence:

Being riclib:

Yagnipedia:

The Tickets:

  • S-526: Content editor — git-native markdown editing with SSE push (done)
  • S-531: Stream marker POC — 28/28, 100% reliability (done)
  • S-532: Stream marker creation — the full UX (next)