When one of these apps publishes a captured note to a shared stream, the real sequence is not one call. Media has to be attached after the post exists, so a post with attachments is created as a draft first and promoted only once the media lands. Images are fitted and re-encoded on the outbound copies while the originals are never touched. Certain image formats are transcoded. A video link from a known provider becomes the post's video, but only when no clip was uploaded. The first generic link becomes a preview card, and that step must fail soft. If media attachment fails, the post is left parked as an inspectable draft rather than deleted, and the response says so.
Every one of those rules is a decision belonging to the service that owns publishing. None of them is a decision the capture apps have any business making — or, worse, making differently from each other.
So the Shared Client Sends One Request
The shared publish client is deliberately, almost disappointingly small: build a multipart request, post it, read the envelope. The entire sequence executes at the owner. The siblings' own image fitters were retired when this landed, because two of them existed and did the same job by different means, and neither should have existed at all.
This is the second half of the boundary discipline. Track 5 so far has been about what the shared repository must not absorb. This is about what it must not reimplement: logic that already has an owner elsewhere. A fat client would have moved the triplication down a layer rather than removing it — three copies of an orchestration, now inside one shared file, parameterized.
Before extracting shared logic, ask whether it belongs to a service rather than to a library. Consolidating three copies into one shared client is an improvement. Discovering that all three were reimplementing something the owning service could do in one request is a bigger one — the logic stops existing in the consumers entirely, which is strictly better than existing once. The instinct to reach for a library is strong enough to skip this question.
The Other Half of Sharing Is Not Code
A thin client only stays thin if the shape it sends is written down somewhere both sides read. This family keeps a contracts document beside the architecture document: the exact request shape for the publish endpoint, the response envelope and what each field means, the canonical record appended after every publish act, the emotion vocabulary and where it is fetched from, the two brain namespaces and the bridge between them, the search envelope every searchable sibling answers with.
These are agreements, not code. They are what lets a client be four lines and still be correct, and they are the artifact that makes it possible to add a fifth sibling without reading the other four's implementations. Shared code and shared contracts are different instruments: code removes duplication, contracts remove the need to coordinate — and a family this size needs both.
Code
The whole client, and the sequence it deliberately does not contain·python
def publish_crumb(base: str, *, body: str, emotion: str,
visibility: str = "public",
files: list[tuple[str, bytes, str]] | None = None) -> dict:
"""Send one multipart request and return the envelope.
Everything the OWNER does, and this client must never attempt:
- draft-first when media is present, promote after it lands
- image fitting and format transcoding on outbound copies
- provider-link -> video, but only when no clip was uploaded
- first generic link -> preview card, fail-soft
- on media failure: park an inspectable draft, do not delete
"""
parts = [("body", body), ("emotion", emotion),
("visibility", visibility)]
return post_multipart(f"{base}/api/stream/posts", parts, files or [])
# The envelope, which is a CONTRACT and not this client's invention:
#
# { "ok": true,
# "post_id": "...",
# "visibility": "public", # what actually LANDED
# "media_copied": true,
# "link_url": "...",
# "detail": null } # human-readable reason on failure
#
# ok:false means the post EXISTS, parked as a draft. Record what
# happened, not what was requested - which is why the lineage record
# carries both:
def build_publication_record(crumb_id: str, response: dict, *,
requested_visibility: str,
emotion: str, body: str) -> dict:
return {
"id": new_id(),
"crumb_id": crumb_id,
"post_id": response.get("post_id"),
"visibility": response.get("visibility"), # what landed
"requested_visibility": requested_visibility, # what was asked
"emotion": emotion,
"body": body, # the EXACT text that left
"media": response.get("image_names", []), # copied filenames
"detail": response.get("detail"),
"published_at": utcnow(),
}
# The two visibility fields differ exactly when a media failure
# parked a draft. Storing only one of them would make the record
# a wish rather than a history.
Find a piece of logic implemented in two or more of your clients — retry policy, pagination, error normalization, file preprocessing. Before extracting it, ask whether the owning service could do it instead, and what it would cost to move it there. Then write down the resulting wire contract as a document, whichever way you decide.
Hint
The strongest signal that logic belongs at the owner is that the clients disagree about it. Two different retry policies against one service means at least one client is wrong about that service's behavior, and neither client is the right place to keep being wrong.
Progress
Progress is local-only — sign in to sync across devices.