Tea journaling on ATProto (alpha)
oolong docs tea-multitenant-refactor.md
12 kB

Tea Multi-Tenant Refactor — Spec #

Status: Active Author: ptdewey (with Claude) Created: 2026-05-07 Last updated: 2026-05-07

Goal #

Enable a tea-tracking sister app ("oolong") to ship as a separate binary that shares arabica's Go core. Both apps reuse identical infrastructure — auth, session/witness/PDS cache stack, Jetstream firehose pipeline, feed/moderation, likes/comments, OG card scaffolding, combo-select, suggestions — and provide their own lexicons, descriptors, and entity-specific UI.

After this refactor, cmd/arabica and cmd/oolong differ only in the App value they construct at startup. Bug fixes and cross-cutting features land once and benefit both apps.

Relationship to entity-descriptor-refactor #

This refactor is a strict superset of docs/entity-descriptor-refactor.md:

  • ED Phase 0 (internal/entities registry) is the foundation. ✅ done
  • ED Phase 1 (templ data switches in feed.templ) is in progress independently and stays on its own track.
  • ED Phases 2-4 were deferred as not paying for themselves on entity-addition alone. This refactor activates them — cross-app reuse needs the data and handler layers to be entity-agnostic, with no escape hatch like "edit the brew handler directly" because there is no brew in oolong.
  • ED Phase 5' (modal shell extraction) lands as part of Phase G here.

The honest tradeoff: re-activating ED's deferred phases is roughly 2-3 weeks of churn through hot files. The oolong project has to justify that. We've decided it does.

What we're optimizing for #

  1. Code reuse without forking. One repo, one Go module, two binaries. Both apps import the same internal/atplatform/... packages. A bug fix in the witness cache benefits both.
  2. App startup is the only branching point. At runtime, downstream code does not know which app it serves. It dispatches via the descriptor registry and reads from the App value threaded through the handler chain.
  3. Add-an-entity friction stays low for both apps. The ~10-edit ceiling the descriptor refactor targets is preserved.

Non-goals #

  • Shared databases. Each app has its own SQLite witness index and BoltDB session store. They can run on the same host but not the same files.
  • Shared OAuth client identity. Each app registers as a separate AT Protocol OAuth client and serves its own client metadata.
  • Plugin or dynamic loading. oolong is built from this repo's source. No Go plugin runtime, no shared-object wizardry.
  • Lexicon code generation. Per ED non-goals, lexicons stay JSON + hand-written models.
  • Feed cross-pollination. oolong users see oolong events; arabica users see arabica events. The federated feed within one app stays scoped to that app's collections.

Architecture sketch #

The App struct #

package domain

type App struct {
    Name        string                  // "arabica", "oolong"
    NSIDBase    string                  // "social.arabica.alpha"
    Descriptors []*entities.Descriptor  // sourced from each app's register.go
    Brand       BrandConfig
}

func (a *App) NSIDs() []string         // descriptor NSIDs + .like + .comment
func (a *App) OAuthScopes() []string   // "atproto" + "repo:<nsid>" per NSID
func (a *App) DescriptorByNSID(string) *entities.Descriptor

Layered package structure (after Phase H) #

internal/
  atplatform/        ← shared across all apps
    domain/          ← App, BrandConfig
    atproto/         ← OAuth, store, cache (now generic)
    firehose/        ← consumer, generic indexer
    feed/            ← generic FeedItem, service, moderation
    database/        ← generic Store interface
    handlers/        ← cross-cutting (auth, feed, likes, comments) + factories
    web/components/  ← entity-agnostic shells (modal, feed card, combo-select)
    ogcard/          ← entity-agnostic card primitives

  arabica/           ← arabica-specific
    register.go      ← descriptor registrations + arabica's App
    handlers/        ← brew/recipe handlers (legitimately bespoke)
    web/             ← entity-specific templ pages, components, assets
    ogcard/          ← per-entity OG cards
    lexicons/        ← arabica's record types

  oolong/            ← tea-specific (sibling layout to arabica/)
    register.go
    ...

cmd/
  arabica/main.go    ← constructs arabica's App, starts server
  oolong/main.go     ← constructs oolong's App, starts server

We start with internal/atplatform/ (not pkg/) because oolong is committed to live in this repo. If a separate repo emerges later, promotion to pkg/ is mechanical.

Hot files that change shape #

  • internal/atproto/store.go (2,239 LOC) → moves to atplatform with ~10 generic methods; per-app entity registration handles ref resolution.
  • internal/atproto/cache.go (297 LOC) → UserCache.Records map[string][]any keyed by NSID.
  • internal/firehose/index.go (2,182 LOC) → recordToFeedItem becomes a registry-driven dispatch using a new Descriptor.RecordToFeedPayload.
  • internal/feed/service.go → FeedItem.Record becomes any + RecordType.

Phased rollout #

# Phase Status Goal Effort LOC delta
A Domain/App layer ✅ done Introduce App and thread it through startup. OAuth scopes and firehose collections flow from App, not constants. 2-3 days +150
B Finish ED phase 1 ✅ done Migrated remaining feed.templ data switches: card class block (collapsed onto feedCardClass(item) helper using Descriptor.Noun), share URL/title via Descriptor.URLPath + RKey()/DisplayTitle(), delete URL via descriptor + brew exception, OG card labels via Descriptor.Noun. — -150
C Cache map ✅ done UserCache typed fields → map[string]any keyed by NSID. Generic SetRecords/InvalidateRecords primitives. Typed wrappers retained for arabica call sites; Phase D removes them. 2-3 days -200
D Generic Store CRUD ✅ done Generic fetchRecord/fetchAllRecords/putRecord/removeRecord primitives in store_generic.go. All 6 entities migrated; per-entity wrappers handle only model construction + ref resolution. Typed cache wrappers removed. store.go shrank from 2239 LOC → 1424 LOC (-815). 4-5 days -700
E Generic feed pipeline ✅ done FeedItem.Record any replaces six typed pointer fields across firehose.FeedItem, feed.FirehoseFeedItem, feed.FeedItem. recordToFeedItem dispatches via entities.GetByNSID + Descriptor.RecordToModel; per-entity ref resolution lives in three named helpers. Action text comes from Descriptor.Noun. Templ feed.templ migrated 64 sites from typed-field reads to nil-safe accessor methods. RKey()/DisplayTitle() type-switch on Record. 4-5 days net ~+34 (LOC trade for entity-add friction reduction)
F Handler/route parameterization ◐ partial Routes for the simple entities (bean, roaster, grinder, brewer) — view, OG image, JSON CRUD, modal partials — register via a loop over App.Descriptors + Handler.EntityRouteBundles(). App.DescriptorByType added. Recipe and brew stay explicit. View/OG handler internals (the per-entity xViewConfig builders) remain typed; oolong can ship its own bundle without touching arabica's handler internals, so the deeper consolidation can wait or stay deferred. 5-7 days net ~-30 (LOC), full oolong-enablement of routing
G Templ shell extraction ◐ partial ModalShell already shipped during ED phase 5'; verified all 5 entity modals use it. Feed filter pills loop over descriptors with new Descriptor.FeedFilterLabel (empty hides). Brand strings (page title, header logo, footer name+tagline, og:site_name, meta description) thread through domain.BrandConfig via Handler.SetBrand/LayoutData.BrandName/BrandTagline/HeaderProps.BrandName/FooterWithBrand. Manage tab tables stay bespoke (each table is genuinely different); profile sections will land alongside Phase H templ split. 3-4 days net ~+50 (LOC) — oolong unblocked for branding
H Package split + cmd binaries ◐ mostly done cmd/server renamed to cmd/arabica. cmd/oolong/main.go constructs its own *domain.App and calls the same internal/atplatform/server.Run arabica uses. Both binaries share the full bootstrap (database, OAuth, firehose, handlers, router, metrics, backups, signal handling) — cmd/arabica/main.go shrank from 617 → 72 LOC; cmd/oolong/main.go is 103 LOC of which 30 lines are the App constructor. Env vars are app-name prefixed (ARABICA_DB_PATH, OOLONG_DB_PATH), data dirs use app.Name so the two binaries don't collide on disk. firehose.ArabicaCollections deleted; BackfillUser takes collections explicitly; admin export reads h.appNSIDs(). Smoke-tested: both binaries boot, arabica serves with descriptors, oolong serves with empty descriptors and refuses entity routes (no entities). Deferred: physically moving shared packages (atproto, firehose, feed, handlers, web/components) under internal/atplatform/... — mechanical but enormous import-rename fanout, low value before tea lexicons exist. 2-3 days net -100 (LOC); oolong now has a working server stack

Total: ~3-5 weeks of refactor work. oolong's entity-specific work (lexicons, models, descriptors, templ pages) begins after Phase H.

Decisions made #

  1. Shared-core location: internal/atplatform/ for now (single repo, atomic refactors). Promote to pkg/ only if oolong lives in its own repo.
  2. Single Go module. No multi-module setup. oolong imports tangled.org/arabica.social/arabica/internal/atplatform/....
  3. Descriptor scope expands. ED's descriptor will gain methods over the refactor: RecordToFeedPayload (Phase E), possibly DefaultModerationPolicy. Each addition is justified by a phase eliminating a specific switch site.
  4. No shared frontend assets between apps. Each app embeds its own static/ and CSS bundle. Shared web/components take templ inputs (App, BrandConfig) and per-app assets compose around them.
  5. Brand customization is config, not code. Color palette, copy strings ("brews" vs "steeps"), default OG colors → BrandConfig on App.
  6. Phase A doesn't move files. App layer threads through existing structure. Package movement is consolidated in Phase H to avoid repeated churn.

Risks #

  • Hot-file churn during refactor. atproto/store.go and firehose/index.go will be touched repeatedly across phases C-F. Mitigate with TDD: every refactor preserves test green, and we add coverage where it's thin before moving fields around.
  • Refactor stalls before oolong ships. If we land Phases A-C and then pause, we have less-tested code with no offsetting product win. Mitigate by sequencing oolong's lexicon JSONs and stub descriptors in parallel from Phase E onward — we should see a "oolong hello world" by end of Phase G.
  • Generics + reflection cost. Phase D's Get[T any] likely needs reflection or per-descriptor decode functions. Test the hot path early (witness cache reads on a populated DB) to confirm no regression.
  • Templ ergonomics for app-themed components. Some shells will need per-app slot content. Fallback is per-app component overrides; we avoid runtime template selection.

Success criteria #

  • cmd/arabica builds and serves with no behavior change at any phase boundary.
  • After Phase H, cmd/oolong (with stub descriptors) builds, runs, and refuses login because no descriptors are registered — proving the App layer is the only branching point.
  • The 27-step entity checklist in CLAUDE.md collapses to ≤10 steps for both arabica and oolong.
  • No test regressions throughout. Coverage is added where the refactor touches uncovered code.
  • docs/entity-descriptor-refactor.md — parent spec (ED phases 0/1/5')
  • docs/plans/2026-05-07-tea-phase-a-domain-layer.md — Phase A detailed plan
  • Subsequent phase plans get written when each phase begins (the codebase shape changes between phases; up-front detailed plans for D-H would be fiction).
  • docs/cafe-and-drinks.md — entity additions that benefit from the same scaffolding