Blog

August 15, 2026 · 12 min read

Solo Architecture Retrospective: What 26 Posts Revealed

One universal iPhone and iPad app and 14 Kubernetes namespaces, drawn on a single page. Most of the 26 incidents shared one shape, and only the device notification path was missing.

  • solo development
  • architecture
  • self-hosting
  • Kubernetes
  • Nostr

Each of the previous 26 posts was one incident. Two code paths read three characters from a note differently; a node was Ready but had no metrics; the console saw a subscription as pro while the app saw free.

While writing each post, I looked only at that place. After finishing all 26, I mapped the whole structure.

The trigger for mapping it was notifications. I could know about a customer-support request only while sitting at my desk. Trying to add one notification made me check the boundary between the device and server again.

From device to cluster

At a glance

Symptom I could know about a customer-support request only while sitting at my desk
Cause No work item owned the path that notifies the device from the server
Fix Put the device, desktop, cluster, and support center on one page and redraw the boundaries. Assign notifications to the app and fix a structure that never puts content in the push payload
Result The change narrowed to three places, and I confirmed that most causes in the previous 26 posts had the same shape

The product surface is one app

JustSend is a universal app targeting iOS 26. TARGETED_DEVICE_FAMILY is 1,2, so iPhone and iPad use the same binary. iPad supports all four rotations.

A universal app needs layout branches. At first I split them with horizontalSizeClass: sidebar when wide, bottom tabs when narrow.

That criterion was not accurate. The comment left in the code states why.

// JustSend/Sources/Memory/Stream/StreamScreen.swift:215-219
/// 폭으로 판정할 수 없다 — split view 칼럼 안에서는 iPad 가로에서도
/// `horizontalSizeClass`가 `.compact`로 내려온다(실측: detail 427pt). 기기 idiom도
/// 답이 아니다: Slide Over로 좁게 띄운 iPad 창에서는 아이폰 도크가 맞다.
private var sidebarOwnsSurface: Bool { externalHomeTab != nil }

When iPad is horizontal and split view is open, the detail column narrows to 427pt and returns compact. Deciding by device type applies an iPad layout to an iPad window shown narrowly in Slide Over, which is also wrong.

Neither width nor device was the answer. The answer was who owns the choice. When the sidebar owns the surface choice, I do not draw the bottom segment. The condition is not drawing the same choice in two places.

Heavy work inside the app runs on the device. An on-device model summarizes and classifies, and a local index answers searches. The app, widget, and share extension use one App Group database.

Only accounts, synchronization, and attachment storage leave for the server.

I use two desktops

On Mac, I use two things: one for writing manuscripts and one for viewing support-center conversations.

Both are Tauri. One is app.solomd, and the other is the support-center client. They share the structure of building the screen with web technology and keeping heavy processing on the Rust side.

Choosing the same structure twice was intentional. When working alone, the cost of learning a tool is the largest item when choosing it. Using the same structure twice lets what I learned the first time apply directly the second time.

I separated device and desktop by user. The product is the app, and the work tool is the desktop. I did not try to make them one thing.

The server is one cluster

There are 14 namespaces. I divide them by service and manage them with GitOps.

Namespace Workloads What
App platform 16 Web, API, backend
Support center 11 Relay, agents, metrics DB
Monitoring 6 Metrics, alerts
GitOps 7 Sync controllers
Mail 1 SMTP, IMAP

The rest are for the auth gate, certificate issuance, router, issue tracker, and experiments.

I keep one cluster to avoid increasing the number of things to manage. Namespaces can grow while the control plane remains one. Upgrades and backups also happen once.

Only the support center uses a different protocol

The app and backend communicate over HTTP. Only the support center differs. I run a Nostr relay where people and agents connect through the same socket.

The base is Buzz: Block's Apache-2.0 open-source Rust workspace with 30 crates. The relay, authentication, agent adapter, and push gateway are separated, and a Helm chart comes with it. docs/nips/ contains 16 drafts defining things the standards do not cover.

I chose a relay instead of a commercial support center because of where conversations remain. If they accumulate in an external store, later export features can bring out only what that store allows.

Here is the event distribution after 21 hours of continuous operation.

kind Count What
7 67 Reactions
5 64 Deletions
9 59 Channel messages
0 37 Profiles
39000 · 39001 · 39002 23 each Channel metadata, admins, members
9007 6 Channel creation

kind:9 is the core. Messages sent by a person from desktop and messages sent by an agent have the same kind. I did not open a management API for agents.

Agents are clients too. They use the same socket, authentication, and event types. A special path would be the only path outside the audit boundary.

I covered separately how I checked the source of truth and permissions on the relay.

I gathered the external contact points in one place

Agents contact five externals: App Store reviews, community forum, mail server, cluster-state reads, and metrics DB.

Instead of giving each of the six agents its own API client, they all pass through one MCP server. The registered tool list differs by role. Write tools are never registered for read-only roles.

Writing has two layers. The environment gate must be open, and someone on the approver list must approve. I covered this in MCP server role scoping.

Most of the effort was schemes and arguments

The relay returned 200 and the agent pods were Running, but no conversation worked. These are the blockers in order.

First, plain ws connections all get dropped. NIP-42 compares the relay tag in an AUTH event with the relay's known address, and buzz-auth::normalize_relay_url also checks the scheme. I was wrong to assume plaintext was sufficient because the call was internal to the cluster. When authentication verifies an address by string comparison, there is no internal-versus-external distinction. TLS terminates at the Ingress, so I used hostAliases to point the relay domain to the Ingress's internal cluster address and connected with wss.

Second, arguments cannot be appended to the MCP execution command. The agent adapter treats the entire string in the environment variable as the executable path and does not pass args separately. Writing care-mcp --role reviews therefore fails because no file has that name.

The fact that the failure was silent was the problem. Instead of an error, the conversation started with no tools attached to the session. The agent answered but could not query anything. I put role-specific wrappers in the image and made them argument-free paths.

Third, raising the permission mode prevents session creation. I requested a bypass mode, but the adapter did not implement it, so session creation itself failed. The turn did not run at all. With the default, the adapter automatically approves permission requests. The setting meant to widen permission blocked execution.

Fourth, model selection works only with ids advertised by the adapter. The adapter exposes only three ids—default, advanced, and lightweight—so a specific model name is ignored with a warning. I fixed the model through the provider environment variable rather than adapter configuration.

Fifth, a channel is visible only when it has a membership event. A relay invite code alone leaves the channel list empty. A kind:39002 event must be published for it to appear in queries. The rule is the same for people and agents.

All five were cases where configuration differed from the contract, not where code itself was wrong. Only the first wrote the cause directly to the log.

Device notifications belong to the app

Mapping the structure revealed a missing path. There was no way for the server to speak to the device first.

That is not needed on Mac. While the desktop app is open, its socket stays alive and new events arrive through it. macOS keeps background processes alive for a long time.

Mobile is different. A mobile operating system terminates a background socket within seconds. Nostr is pull-based: the client must set a filter and ask for events. Without a socket, it cannot ask.

So the server side needs a component that observes on its behalf. It keeps the filter while the client is absent and wakes the device with a platform push when there is a match.

At first I tried to attach the mobile client included in the support-center project. There was no reason to. An app already runs on iPhone and iPad, and there was no basis for making people install another app for one notification.

So I assigned notifications to the app's work. The support center owns the relay, and the app owns waking the device. This boundary was invisible until I put the whole structure on one page.

The push path carries no content

I fixed the push structure while fixing the boundary. Push passes through Apple. The reason I ran a relay was to keep conversations out of someone else's store; putting content into push would control one path while sending the same content through another.

Buzz has defined this as an NIP-PL draft, and I decided to use that structure as-is.

The old approach registers APNs device tokens with a custom HTTP API or receives the full event JSON at a callback URL. Both make platform plumbing central to the protocol. NIP-PL treats the protocol object as permission, not as a transport. It is a signed, expiring, revocable filter; which vendor performs the wake-up is only an execution profile of that permission.

A path where push only wakes the device

The lease is a kind:30350 event.

  • The d tag is a random id different for every installation. Installations are not coupled, so revoking one does not affect the others
  • expiration is public and required. A push permission without expiry cannot be created
  • The delivery endpoint and subscription filter are encrypted with the executor's public key

The payload rule is the core. Push contains only a fixed reconnect instruction written by the delivery service. Relay-provided bytes, event id, body, URL, and ciphertext are all forbidden. The device wakes, reconnects to the relay it already knows, and retrieves the source of truth normally. Push may be lost or duplicated, so the relay remains the sole source of truth.

The gateway code has the same constraint in a comment.

// crates/buzz-push-gateway/src/apns.rs:76-78
/// Closed APNs transport controls. No field can be serialized into application
/// content; the concrete transport always uses `APNS_RECONNECT_PAYLOAD`.

No field can be serialized as application content, and the actual transport always uses a fixed payload. Rules are often written in documentation and omitted in implementation; here the type restricts it.

There are three places to change: the app's notification permission and token registration, gateway deployment, and lease publication by the app. All three locations came from the boundary decision above.

Most causes in the previous 26 posts had the same shape

After mapping the structure, I reread the earlier posts. The incidents differed, but their causes had similar forms.

  • The core was split into a separate package, but the build read a copy inside the repository
  • Colors were organized as tokens, but 367 call sites still used the old names
  • The server was designated to decide subscription state, but decision code remained in the app
  • The node was Ready and all 13 exporters were alive, but metrics did not arrive
  • The pod was Running, but one container held readiness down
  • Role permissions were written in the prompt, but not reflected in the tool list

All are places where what was declared and what ran diverged. Neither side's code was wrong. Both were correct, but they were each treating a different thing as correct.

This kind of mismatch is easy when working alone. When the person who declares and implements is the same, there is no checking step between them. Inside one person's understanding, two values are one, so there is no trigger to check where they diverge.

The notification had the same shape. While setting up the support center, I decided that a person would watch the conversation, but I wrote nowhere what happens when that person leaves the desk. Something that does not exist does not appear as a mismatch. You must list the items before you can see what is missing.

Writing the posts served as that check. It was not the original purpose.

To write one post, I have to measure the numbers again and reread the code. That process exposed places where the document and implementation differed. While writing the search post, I found that the three-character condition differed between the body and code. While writing the attachment-storage post, I found that two posts described differently what the server holds. While writing about permissions, I counted the role tools and found write tools attached to a role that should be read-only.

To explain something, you have to know it exactly, and the checking process exposes divergences. I did not write with the purpose of replacing code review, but the result was similar.

This time I put the structure down first and set the boundaries.

If you hit this

  • Is there a path for the server to speak to the device first? If not, which work item owns it?
  • Does the payload sent through platform push contain body text or an event identifier?
  • Does authentication compare addresses as strings? If so, does an internal call use the same scheme?
  • Can arguments be appended to the command passed to an external process? If not, what becomes silently empty when you append them?
  • Is a setting intended to widen permissions instead preventing session creation?
  • Are the declared boundary and the path the build actually reads the same?