···11+# Entity Descriptor Refactor — Spec
22+33+**Status:** Phase 0 complete; phase 1 in progress
44+**Author:** ptdewey
55+**Created:** 2026-04-25
66+**Last updated:** 2026-04-25
77+88+## Goal
99+1010+Replace the per-entity switch/method fan-out with a single `entities.Descriptor`
1111+registry. Adding a new entity (cafe, drink, etc.) should require ~10 edits
1212+instead of the ~27 documented in `CLAUDE.md`. Concretely: collapse the
1313+dozen-plus switches scattered across the feed templ, OG cards, modals,
1414+suggestions, and routing into one lookup table.
1515+1616+## Background
1717+1818+Arabica supports seven record types today (bean, brew, brewer, grinder, like,
1919+recipe, roaster). The "Adding a New Entity Type" checklist in `CLAUDE.md`
2020+enumerates ~27 separate edits across as many files. Most of those edits are
2121+mechanical scaffolding rather than meaningful per-entity logic:
2222+2323+- `internal/atproto/cache.go`: 12+ identical `SetX`/`InvalidateX` methods
2424+- `internal/atproto/store.go`: parallel `GetXByRKey`/`ListX` methods that all
2525+ follow the same witness → convert → resolve refs → fallback-to-PDS chain
2626+- `internal/web/pages/feed.templ`: five separate switches over `RecordType`
2727+ (card class, action text, share URL, delete URL, delete confirm copy)
2828+- `internal/web/components/dialog_modals.templ`: a nested type+field switch
2929+ in `getStringValue`, plus five ~340-LOC modal components that share a
3030+ common shell but differ in field bodies
3131+- `internal/handlers/entity_views.go`: four near-identical entity view handlers
3232+- `internal/ogcard/entities.go`: five Draw functions sharing one structure
3333+- `internal/firehose/index.go`: a 135-line `recordToFeedItem` switch
3434+- `internal/suggestions/suggestions.go` + `internal/handlers/suggestions.go`:
3535+ parallel maps of per-entity config
3636+3737+The pattern that ties all of these together is "given a `RecordType`, do X."
3838+A single registry keyed by `RecordType` removes the duplication.
3939+4040+## What we're optimizing for
4141+4242+The maintainer's stated priorities, ranked:
4343+4444+1. **Adding new entity types** (cafe, drink, future). The 27-step checklist
4545+ is the headline pain point.
4646+2. **Adding/changing fields** on existing entities. Today this means editing
4747+ ~6-8 files; the refactor can bring it down to ~4-5 in some cases.
4848+3. **Preserving room for unique behavior** on entities like brew (multi-ref
4949+ resolution, espresso/pourover variants) and recipe (pours, computed
5050+ ratios). The refactor must NOT force these through a one-size-fits-all
5151+ abstraction.
5252+5353+LOC reduction is **not** a primary goal. Realistic net delta after the full
5454+rollout is ~600-1000 LOC removed minus ~300-500 LOC of new descriptor/helper
5555+code. That's meaningful but not the headline.
5656+5757+## Non-goals
5858+5959+- Replacing per-entity record conversion (`RecordToBean`, `BeanToRecord`,
6060+ etc.) — these carry validation and shape logic; leave them
6161+- Code generation from lexicon JSON — too much annotation overhead for the
6262+ win, and the lexicons are not rich enough to drive Go types + templ markup
6363+- Restructuring `SessionCache` or the firehose pipeline — separate concerns,
6464+ with their own audit findings to address later
6565+- Building cafe/drink scaffolding before the abstraction lands — avoid a
6666+ moving target
6767+- A declarative form-spec DSL for modals (see "What we changed our minds
6868+ about" below)
6969+7070+## What goes in the descriptor
7171+7272+Pure data and small accessors that vary across entities:
7373+7474+- `Type` (`RecordType`), `NSID`
7575+- `DisplayName` (`"Bean"`), `Noun` (`"bean"`), `URLPath` (`"beans"`)
7676+- `GetField(entity, field) (string, bool)` — for templ form prefill
7777+- (later phases) `CardClass`, `OGAccentColor`, `SuggestionConfig`
7878+7979+What stays as code (NOT in the descriptor):
8080+8181+- Record conversion (rich, hand-written, type-safe)
8282+- Templ form bodies (genuinely different layouts per entity)
8383+- Validation rules
8484+- Container-specific record accessors (e.g. `(*FeedItem).Record()`) — see
8585+ "Design choice" below
8686+8787+## Design choice: descriptor describes records, containers know themselves
8888+8989+Earlier versions of this spec put a `Record func(*feed.FeedItem) any`
9090+accessor on `Descriptor`. That coupled `entities` to `feed` and made
9191+FeedItem a privileged container — but most future descriptor consumers
9292+(OG cards, view handlers, store CRUD) operate on raw records, not feed
9393+items.
9494+9595+The current shape: `entities` only depends on `lexicons` (and `models`
9696+for field accessors). Each container that holds typed record fields
9797+exposes its own way to retrieve a record by `RecordType`. For
9898+`FeedItem`, that's a `Record() any` method; the small switch lives next
9999+to the typed fields, where adding a new entity field obviously requires
100100+updating it.
101101+102102+This keeps the descriptor reusable across every container without
103103+re-design.
104104+105105+## Phased rollout
106106+107107+**Updated rollout** based on what the maintainer actually values. Phases
108108+2-4 are deprioritized because they're invisible to day-to-day work; the
109109+focus is on changes that affect entity addition and field-level edits.
110110+111111+Each phase ships independently. Stop anywhere if the win plateaus.
112112+113113+| Phase | Scope | Status | Approx. LOC |
114114+|---|---|---|---|
115115+| **0. Foundation** | New `internal/entities` package + registry. Migrate `ActionText` and `getStringValue` as proof. | ✅ done | net 0 |
116116+| **1. Templ data switches** | Remaining four switches in `feed.templ` (card class, title, share URL, delete URL+confirm). OG card accent/label lookup. | ▶ next | −150 |
117117+| **5'. Modal shell extraction** *(replaces old phase 5)* | Extract a shared `EntityModalShell` component (dialog mechanics, header, error display, footer/buttons). Per-entity field bodies stay as their own templ files. | planned | −100 to −150 |
118118+| **2. Cache map** | `UserCache.Beans/Roasters/...` → `map[string]any` keyed by NSID. | deferred | −200 |
119119+| **3. Generic store CRUD** | `Get[T](ctx, nsid, rkey)`, `List[T]` on `AtprotoStore`. | deferred | −400 |
120120+| **4. View handler unification** | Collapse simple entity view handlers; brew/recipe stay bespoke. | deferred | −500 |
121121+| **6. Cleanup pass** | Modal route loop, suggestions config from descriptor, dirty-tracking → TTL, PublicClient resolver cache. | deferred | −80 |
122122+123123+Phases 2-4 are still legitimate refactors, but their day-to-day value is
124124+low for the maintainer's stated priorities. They land if they fall out
125125+cheaply during other work, or if the storage layer is being refactored
126126+for another reason (e.g. quickslice integration).
127127+128128+## What we changed our minds about
129129+130130+### Original phase 5 (full FieldSpec consolidation): rejected
131131+132132+The original plan was to replace all five dialog modals with a single
133133+`EntityModal(descriptor, []FieldSpec, entity)` driven by a declarative
134134+field spec. After looking at what the modals actually contain, this is
135135+the wrong scope:
136136+137137+- Plain text/number/dropdown fields fit cleanly into FieldSpec (~70%)
138138+- The bean modal's roaster picker (~80 lines of Alpine state) doesn't
139139+- The brewer modal's espresso/pourover conditional sections don't
140140+- The bean rating slider (stateful Alpine widget) doesn't
141141+142142+To absorb the bespoke widgets, FieldSpec would need predicates, slots,
143143+init-state passthrough, and raw-templ escape hatches. At that point
144144+it's a forms DSL, debugging means reading the spec interpreter, and
145145+adding a field requires reasoning about how the renderer interprets
146146+your spec.
147147+148148+### Replacement: modal shell extraction (phase 5')
149149+150150+Extract only the cross-cutting consistency layer: dialog open/close
151151+mechanics, header, validation error display, footer with cancel/submit
152152+buttons, dirty-tracking. Each entity's modal still has its own templ
153153+file with its own field body — the body is where editing actually
154154+happens, and it stays readable as plain templ.
155155+156156+Trade-offs vs the rejected design:
157157+158158+- LOC saved: ~100-150 instead of ~300, but **zero DSL risk**
159159+- Adding a new entity: copy an existing modal, change the fields. Same
160160+ friction as today for the body, but the shell is free.
161161+- Cross-cutting modal changes (e.g., new error display): single edit,
162162+ same as the rejected design.
163163+- Field changes: same friction as today (edit the entity's modal templ
164164+ directly). The "field changes get easier" pitch was always weak.
165165+166166+## Risks
167167+168168+- **Templ ergonomics**: descriptors return primitives, not templ
169169+ components. Switches that dispatch to *different rendering* must
170170+ stay (we only flatten *data* switches).
171171+- **Over-applying the abstraction**: some entities (brew, recipe) are
172172+ legitimately different. The discipline is to leave them bespoke,
173173+ not strain the descriptor to fit them.
174174+- **Refactor stalling**: phases 0-1 + 5' ship the real win even if 2-4
175175+ never happen. Don't gate the value on the deferred phases.
176176+177177+## Success criteria
178178+179179+- Phase 0 lands with no behavior change and tests green. ✅
180180+- Adding a hypothetical 8th entity (cafe) requires touching strictly
181181+ fewer files than the current checklist documents.
182182+- The 27-step checklist in `CLAUDE.md` shrinks to reflect the new
183183+ ceiling after each phase.
184184+- No new abstractions are introduced beyond the descriptor, the modal
185185+ shell, and (later, if we get there) the generic CRUD helper. We are
186186+ collapsing duplication, not building a framework.
187187+- Brew and recipe view/modal code remains bespoke and uncomplicated by
188188+ the descriptor system.
189189+190190+## Decisions made
191191+192192+1. **Package location**: `internal/entities/` ✅
193193+2. **`Get` return shape**: `*Descriptor` (nil on miss) ✅
194194+3. **Descriptor scope**: record-centric, not container-centric. `entities`
195195+ does not import `feed`. Containers expose their own record accessors. ✅
196196+4. **Like registration**: skipped (no entity page). Revisit if a feed
197197+ migration needs it.
198198+5. **Cafe & drink**: defer registration until phase 1 ships. Adding them
199199+ should be the smoke test for the refactor.
200200+6. **Phase 5**: rejected as originally specified. Replaced with shell
201201+ extraction (phase 5'). ✅
202202+203203+## Related work
204204+205205+- `docs/cafe-and-drinks.md` — upcoming entity types this refactor unblocks
206206+- `docs/quickslice-implementation-plan.md` — separate read-path refactor;
207207+ composes cleanly with this one
208208+- `docs/design-audit.md` — broader audit notes
209209+- `docs/plans/2026-04-25-entity-descriptor-phase-0.md` — phase 0 plan
210210+- `docs/plans/2026-04-25-entity-descriptor-phase-1.md` — phase 1 plan
···11+# Entity Descriptor — Phase 0 Foundation
22+33+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to
44+> implement this plan task-by-task.
55+66+**Goal:** Introduce the `internal/entities` package with a `Descriptor` type and
77+registry, register all current record types, and migrate two representative call
88+sites (one templ, one Go) as proof. No behavior change; pure refactor.
99+1010+**Parent spec:** `docs/entity-descriptor-refactor.md`
1111+1212+**Tech Stack:** Go 1.26, Templ, no new dependencies.
1313+1414+---
1515+1616+## Major Design Changes
1717+1818+### The new abstraction
1919+2020+A single struct, `entities.Descriptor`, captures the per-entity data that
2121+callers across the codebase dispatch on. One descriptor is registered per record
2222+type at package `init()`. Callers do `entities.Get(item.RecordType)` and read
2323+fields off the descriptor instead of writing a `switch`.
2424+2525+The descriptor stores **data and small accessors** — not templ components, not
2626+record conversion, not validation. Anything that genuinely varies in _structure_
2727+per entity (form layouts, conversion logic, ref resolution) stays as code.
2828+2929+### Why a registry instead of generics or interfaces
3030+3131+- **Generics** force every caller to be parameterized on `T`, which doesn't work
3232+ for templ files (templ has no type parameters) and would force the feed item
3333+ to be generic too.
3434+- **An interface on the model** (e.g., `Bean implements EntityDescriptor`) works
3535+ for Go callers but not for templ — templ needs to dispatch on `RecordType` (a
3636+ string) coming off a `FeedItem`, where the typed model pointer may be nil.
3737+- **A registry keyed by `RecordType`** works in both Go and templ, requires zero
3838+ changes to existing types, and stays opt-in: callers who don't need it ignore
3939+ it.
4040+4141+### What is NOT changing in phase 0
4242+4343+- The `Store` interface and `AtprotoStore` implementation
4444+- `UserCache` typed fields
4545+- Per-entity record conversion (`records.go`)
4646+- Per-entity templ form layouts
4747+- The 5-switch structure of `feed.templ` (only `ActionText` is migrated this
4848+ phase; the others are phase 1)
4949+- Routing (entity routes still registered explicitly; loop comes in phase 6)
5050+5151+### What this phase enables
5252+5353+After phase 0, every subsequent phase migrates additional call sites onto the
5454+same registry. The generic-store work in phase 3 reuses `Descriptor.NSID` for
5555+collection lookup. The OG card work in phase 1 reuses `Descriptor.DisplayName`.
5656+Phase 0's value is foundational, not direct LOC savings.
5757+5858+### Boundaries and invariants
5959+6060+- **The descriptor never holds entity data, only metadata.** A `Descriptor` for
6161+ "bean" is a singleton; there is one of it for the whole program.
6262+- **`any` is contained.** It appears on `Record` and `GetField` because templ
6363+ can't be parameterized on `T`. Callers never store `any` — they extract a
6464+ typed pointer or a string and move on.
6565+- **Registration panics on duplicates.** Catches double-registration bugs at
6666+ startup, before serving traffic.
6767+- **Registration is package-private to `entities`.** No one outside the package
6868+ can mutate the registry.
6969+7070+---
7171+7272+## Phase 0: Foundation
7373+7474+### Task 1: Create the `entities` package
7575+7676+**Files:**
7777+7878+- Create: `internal/entities/entities.go`
7979+- Create: `internal/entities/entities_test.go`
8080+8181+**Step 1: Write the descriptor type and registry**
8282+8383+Create `internal/entities/entities.go`:
8484+8585+```go
8686+// Package entities provides a registry of descriptors for each Arabica record
8787+// type. A descriptor captures the per-entity data that callers in feed, templ,
8888+// handlers, and ogcard dispatch on, replacing scattered switch statements with
8989+// a single lookup.
9090+package entities
9191+9292+import (
9393+ "fmt"
9494+ "sort"
9595+9696+ "tangled.org/arabica.social/arabica/internal/feed"
9797+ "tangled.org/arabica.social/arabica/internal/lexicons"
9898+)
9999+100100+// Descriptor describes one Arabica record type.
101101+type Descriptor struct {
102102+ Type lexicons.RecordType
103103+ NSID string
104104+ DisplayName string // "Bean"
105105+ Noun string // "bean" — appears in copy: "added a new bean"
106106+ URLPath string // "beans" — share URLs and routes
107107+108108+ // Record returns the typed record pointer from a FeedItem (e.g. item.Bean),
109109+ // or nil if the FeedItem holds no record of this type.
110110+ Record func(*feed.FeedItem) any
111111+112112+ // GetField extracts one named string field from a typed model pointer for
113113+ // form prefill. Returns ("", false) if entity is nil or field is unknown.
114114+ GetField func(entity any, field string) (string, bool)
115115+}
116116+117117+var registry = map[lexicons.RecordType]*Descriptor{}
118118+119119+// Register adds a descriptor. Called once per entity at package init.
120120+// Panics on duplicate registration to catch wiring bugs at startup.
121121+func Register(d *Descriptor) {
122122+ if _, ok := registry[d.Type]; ok {
123123+ panic(fmt.Sprintf("entities: duplicate descriptor for %s", d.Type))
124124+ }
125125+ registry[d.Type] = d
126126+}
127127+128128+// Get returns the descriptor for a record type, or nil if unregistered.
129129+func Get(rt lexicons.RecordType) *Descriptor { return registry[rt] }
130130+131131+// All returns descriptors in stable order (by RecordType). Use for route loops.
132132+func All() []*Descriptor {
133133+ out := make([]*Descriptor, 0, len(registry))
134134+ for _, d := range registry {
135135+ out = append(out, d)
136136+ }
137137+ sort.Slice(out, func(i, j int) bool { return out[i].Type < out[j].Type })
138138+ return out
139139+}
140140+```
141141+142142+**Step 2: Write the registry test**
143143+144144+Create `internal/entities/entities_test.go` covering:
145145+146146+- `Get` returns the right descriptor for each registered type
147147+- `Get` returns `nil` for an unknown type
148148+- `All()` returns descriptors in sorted order
149149+- Duplicate registration panics
150150+151151+Use `testify/assert` (project convention).
152152+153153+### Task 2: Register all current record types
154154+155155+**Files:**
156156+157157+- Create: `internal/entities/register.go`
158158+- Create: `internal/entities/fields.go`
159159+160160+**Step 1: Write registration**
161161+162162+Create `internal/entities/register.go`:
163163+164164+```go
165165+package entities
166166+167167+import (
168168+ "tangled.org/arabica.social/arabica/internal/atproto"
169169+ "tangled.org/arabica.social/arabica/internal/feed"
170170+ "tangled.org/arabica.social/arabica/internal/lexicons"
171171+)
172172+173173+func init() {
174174+ Register(&Descriptor{
175175+ Type: lexicons.RecordTypeBean, NSID: atproto.NSIDBean,
176176+ DisplayName: "Bean", Noun: "bean", URLPath: "beans",
177177+ Record: func(i *feed.FeedItem) any { return i.Bean },
178178+ GetField: beanField,
179179+ })
180180+ Register(&Descriptor{
181181+ Type: lexicons.RecordTypeRoaster, NSID: atproto.NSIDRoaster,
182182+ DisplayName: "Roaster", Noun: "roaster", URLPath: "roasters",
183183+ Record: func(i *feed.FeedItem) any { return i.Roaster },
184184+ GetField: roasterField,
185185+ })
186186+ Register(&Descriptor{
187187+ Type: lexicons.RecordTypeGrinder, NSID: atproto.NSIDGrinder,
188188+ DisplayName: "Grinder", Noun: "grinder", URLPath: "grinders",
189189+ Record: func(i *feed.FeedItem) any { return i.Grinder },
190190+ GetField: grinderField,
191191+ })
192192+ Register(&Descriptor{
193193+ Type: lexicons.RecordTypeBrewer, NSID: atproto.NSIDBrewer,
194194+ DisplayName: "Brewer", Noun: "brewer", URLPath: "brewers",
195195+ Record: func(i *feed.FeedItem) any { return i.Brewer },
196196+ GetField: brewerField,
197197+ })
198198+ Register(&Descriptor{
199199+ Type: lexicons.RecordTypeRecipe, NSID: atproto.NSIDRecipe,
200200+ DisplayName: "Recipe", Noun: "recipe", URLPath: "recipes",
201201+ Record: func(i *feed.FeedItem) any { return i.Recipe },
202202+ GetField: recipeField,
203203+ })
204204+ Register(&Descriptor{
205205+ Type: lexicons.RecordTypeBrew, NSID: atproto.NSIDBrew,
206206+ DisplayName: "Brew", Noun: "brew", URLPath: "brews",
207207+ Record: func(i *feed.FeedItem) any { return i.Brew },
208208+ GetField: nil, // brew has no edit modal that needs prefill
209209+ })
210210+ // Like is intentionally omitted — has no entity page or modal.
211211+}
212212+```
213213+214214+**Step 2: Write the per-entity field accessors**
215215+216216+Create `internal/entities/fields.go` with `beanField`, `roasterField`,
217217+`grinderField`, `brewerField`, `recipeField`. Each is a small switch over the
218218+field-name strings used by `dialog_modals.templ`. Example:
219219+220220+```go
221221+func beanField(e any, field string) (string, bool) {
222222+ b, ok := e.(*models.Bean)
223223+ if !ok || b == nil {
224224+ return "", false
225225+ }
226226+ switch field {
227227+ case "name": return b.Name, true
228228+ case "origin": return b.Origin, true
229229+ case "variety": return b.Variety, true
230230+ case "process": return b.Process, true
231231+ case "description": return b.Description, true
232232+ }
233233+ return "", false
234234+}
235235+```
236236+237237+The Recipe case in the original `getStringValue` formats `coffee_amount` and
238238+`water_amount` as `%.1f`. Move that formatting into `recipeField` — the
239239+descriptor is the right home for it.
240240+241241+### Task 3: Migrate `feed.templ` `ActionText`
242242+243243+**Files:**
244244+245245+- Modify: `internal/web/pages/feed.templ`
246246+247247+**Step 1: Replace the switch**
248248+249249+Lines `289–348` of `internal/web/pages/feed.templ` contain six near-identical
250250+cases that differ only in noun. Replace the whole `templ ActionText(...)` body
251251+with:
252252+253253+```templ
254254+templ ActionText(item *feed.FeedItem) {
255255+ if d := entities.Get(item.RecordType); d != nil && d.Record(item) != nil {
256256+ added a
257257+ <a href={ templ.SafeURL(getFeedItemShareURL(item)) }
258258+ class="underline hover:text-brown-900">new { d.Noun }</a>
259259+ { " " }
260260+ @components.TypeBadge(d.Noun)
261261+ } else {
262262+ { item.Action }
263263+ }
264264+}
265265+```
266266+267267+Add the `entities` import to the templ file.
268268+269269+**Step 2: Regenerate**
270270+271271+Run `templ generate -f internal/web/pages/feed.templ`.
272272+273273+### Task 4: Migrate `getStringValue` in `dialog_modals.templ`
274274+275275+**Files:**
276276+277277+- Modify: `internal/web/components/dialog_modals.templ`
278278+279279+**Step 1: Replace the function**
280280+281281+Lines `935–1016` of `internal/web/components/dialog_modals.templ` contain the
282282+nested type+field switch. Replace with:
283283+284284+```go
285285+func getStringValue(entity interface{}, field string) string {
286286+ if entity == nil {
287287+ return ""
288288+ }
289289+ rt := recordTypeOf(entity)
290290+ d := entities.Get(rt)
291291+ if d == nil || d.GetField == nil {
292292+ return ""
293293+ }
294294+ v, _ := d.GetField(entity, field)
295295+ return v
296296+}
297297+298298+// recordTypeOf maps a typed model pointer to its RecordType. Local helper
299299+// because callers here already hold a typed pointer; pulling this onto the
300300+// model would require a phase-4 model unification.
301301+func recordTypeOf(entity any) lexicons.RecordType {
302302+ switch entity.(type) {
303303+ case *models.Bean: return lexicons.RecordTypeBean
304304+ case *models.Roaster: return lexicons.RecordTypeRoaster
305305+ case *models.Grinder: return lexicons.RecordTypeGrinder
306306+ case *models.Brewer: return lexicons.RecordTypeBrewer
307307+ case *models.Recipe: return lexicons.RecordTypeRecipe
308308+ }
309309+ return ""
310310+}
311311+```
312312+313313+Add the `entities` import.
314314+315315+**Step 2: Regenerate**
316316+317317+Run `templ generate -f internal/web/components/dialog_modals.templ`.
318318+319319+### Task 5: Verify
320320+321321+**Commands:**
322322+323323+```bash
324324+go vet ./...
325325+go build ./...
326326+go test ./...
327327+just run
328328+```
329329+330330+**Manual smoke test:**
331331+332332+- Load `/feed` — `ActionText` for each entity type should render the same as
333333+ before
334334+- Open the bean edit modal from `/my-coffee` — fields should prefill identically
335335+- Repeat for roaster, grinder, brewer, recipe modals
336336+337337+**Expected delta:**
338338+339339+- ~110 LOC removed (`ActionText` switch: −48, `getStringValue` switch: −62)
340340+- ~90 LOC added (new `entities` package)
341341+- Net flat, but the foundation now exists for phases 1–6 to compound on.
342342+343343+---
344344+345345+## Out of scope for phase 0
346346+347347+These belong to later phases (see parent spec for full rollout):
348348+349349+- Remaining four switches in `feed.templ` (phase 1)
350350+- OG card consolidation (phase 1)
351351+- Cache typed fields → map (phase 2)
352352+- Generic store CRUD (phase 3)
353353+- View handler unification (phase 4)
354354+- Dialog modal consolidation (phase 5)
355355+- Routing loop, suggestions config, dirty-tracking TTL (phase 6)
356356+357357+## Decisions to confirm before starting
358358+359359+1. **Package location:** `internal/entities/` — confirmed unless you'd prefer
360360+ `internal/lexicons/`.
361361+2. **`Get` return shape:** `*Descriptor` (nil on miss) — idiomatic Go. Switch to
362362+ `(*Descriptor, bool)` if you'd rather be explicit.
363363+3. **Like registration:** skipped in this phase (no entity page); revisit if a
364364+ feed migration needs it.
···11+# Entity Descriptor — Phase 1: Templ Data Switches
22+33+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to
44+> implement this plan task-by-task.
55+66+**Goal:** Migrate the remaining `RecordType` switches in `feed.templ` (share
77+URL, share title, delete URL) onto the descriptor + new `FeedItem` accessor
88+methods. Replace hardcoded entity labels in OG card constructors with
99+`Descriptor.Noun`. No behavior change; pure refactor.
1010+1111+**Parent spec:** `docs/entity-descriptor-refactor.md`
1212+**Previous phase:** `docs/plans/2026-04-25-entity-descriptor-phase-0.md`
1313+1414+**Tech Stack:** Go 1.26, Templ, no new dependencies.
1515+1616+---
1717+1818+## Major Design Changes
1919+2020+### Container-side accessors on `FeedItem`
2121+2222+Phase 0 added `(*FeedItem).Record()` to give callers a typed-pointer
2323+accessor without a switch. Phase 1 extends the same pattern with two
2424+more accessors:
2525+2626+- `(*FeedItem).RKey() string` — the rkey of whichever record is set
2727+- `(*FeedItem).DisplayTitle() string` — a human-readable title for
2828+ share UI; brew is special-cased (uses bean name)
2929+3030+Both methods follow the same convention: a single switch in
3131+`feed/service.go`, co-located with the typed fields. Adding a new
3232+entity to FeedItem requires updating both methods, which is obvious
3333+because they live next to the field declarations.
3434+3535+### Rule of thumb confirmed
3636+3737+We are flattening **data switches** (URLs, labels, titles) but keeping
3838+**rendering switches** (which templ component to invoke). The
3939+`feed.templ` content-rendering switch at line 212 (dispatching to
4040+`BeanContent`, `RoasterContent`, etc.) is **not** migrated — it
4141+dispatches to genuinely different templ components, which the
4242+descriptor pattern can't carry.
4343+4444+### What is NOT changing in phase 1
4545+4646+- The `Store` / `AtprotoStore` interfaces
4747+- `UserCache` typed fields
4848+- Per-entity record conversion (`records.go`)
4949+- The content-rendering switch in `feed.templ` (line 212)
5050+- Per-entity `Draw*Card` functions in `ogcard/entities.go` (the field
5151+ rendering is genuinely different per entity)
5252+- `getEditURL` (only handles brew today; not worth touching)
5353+5454+### What this phase enables
5555+5656+After phase 1, the feed templ has no remaining data switches over
5757+`RecordType`. The next migration target (phase 5' modal shell
5858+extraction) is independent and can land at any time.
5959+6060+---
6161+6262+## Phase 1: Tasks
6363+6464+### Task 1: Add `RKey()` and `DisplayTitle()` accessors to `FeedItem`
6565+6666+**Files:**
6767+6868+- Modify: `internal/feed/service.go`
6969+7070+**Step 1: Add `RKey()` method**
7171+7272+After the existing `Record()` method, add:
7373+7474+```go
7575+// RKey returns the record key of whichever typed record is set on this
7676+// FeedItem, or "" if none. Lets callers build URLs without a type switch.
7777+func (f *FeedItem) RKey() string {
7878+ switch f.RecordType {
7979+ case lexicons.RecordTypeBean:
8080+ if f.Bean != nil {
8181+ return f.Bean.RKey
8282+ }
8383+ case lexicons.RecordTypeRoaster:
8484+ if f.Roaster != nil {
8585+ return f.Roaster.RKey
8686+ }
8787+ case lexicons.RecordTypeGrinder:
8888+ if f.Grinder != nil {
8989+ return f.Grinder.RKey
9090+ }
9191+ case lexicons.RecordTypeBrewer:
9292+ if f.Brewer != nil {
9393+ return f.Brewer.RKey
9494+ }
9595+ case lexicons.RecordTypeRecipe:
9696+ if f.Recipe != nil {
9797+ return f.Recipe.RKey
9898+ }
9999+ case lexicons.RecordTypeBrew:
100100+ if f.Brew != nil {
101101+ return f.Brew.RKey
102102+ }
103103+ }
104104+ return ""
105105+}
106106+```
107107+108108+**Step 2: Add `DisplayTitle()` method**
109109+110110+```go
111111+// DisplayTitle returns a human-readable title for share UI. Brew is
112112+// special-cased: brews don't have a name field, so we fall back to the
113113+// associated bean's name (or origin).
114114+func (f *FeedItem) DisplayTitle() string {
115115+ switch f.RecordType {
116116+ case lexicons.RecordTypeBrew:
117117+ if f.Brew != nil && f.Brew.Bean != nil {
118118+ if f.Brew.Bean.Name != "" {
119119+ return f.Brew.Bean.Name
120120+ }
121121+ return f.Brew.Bean.Origin
122122+ }
123123+ return "Coffee Brew"
124124+ case lexicons.RecordTypeBean:
125125+ if f.Bean != nil {
126126+ if f.Bean.Name != "" {
127127+ return f.Bean.Name
128128+ }
129129+ return f.Bean.Origin
130130+ }
131131+ return "Coffee Bean"
132132+ case lexicons.RecordTypeRoaster:
133133+ if f.Roaster != nil {
134134+ return f.Roaster.Name
135135+ }
136136+ return "Roaster"
137137+ case lexicons.RecordTypeGrinder:
138138+ if f.Grinder != nil {
139139+ return f.Grinder.Name
140140+ }
141141+ return "Grinder"
142142+ case lexicons.RecordTypeBrewer:
143143+ if f.Brewer != nil {
144144+ return f.Brewer.Name
145145+ }
146146+ return "Brewer"
147147+ case lexicons.RecordTypeRecipe:
148148+ if f.Recipe != nil {
149149+ return f.Recipe.Name
150150+ }
151151+ return "Recipe"
152152+ }
153153+ return "Arabica"
154154+}
155155+```
156156+157157+### Task 2: Migrate `getFeedItemShareURL` in `feed.templ`
158158+159159+**Files:**
160160+161161+- Modify: `internal/web/pages/feed.templ`
162162+163163+**Step 1: Replace the switch**
164164+165165+Replace lines `471-499` (`getFeedItemShareURL`) with:
166166+167167+```go
168168+func getFeedItemShareURL(item *feed.FeedItem) string {
169169+ if d := entities.Get(item.RecordType); d != nil {
170170+ if rkey := item.RKey(); rkey != "" {
171171+ return fmt.Sprintf("/%s/%s?owner=%s", d.URLPath, rkey, item.Author.DID)
172172+ }
173173+ }
174174+ return fmt.Sprintf("/profile/%s", item.Author.DID)
175175+}
176176+```
177177+178178+**Step 2: Regenerate**
179179+180180+```bash
181181+templ generate -f internal/web/pages/feed.templ
182182+```
183183+184184+### Task 3: Migrate `getFeedItemShareTitle` in `feed.templ`
185185+186186+**Files:**
187187+188188+- Modify: `internal/web/pages/feed.templ`
189189+190190+**Step 1: Replace the switch**
191191+192192+Replace lines `501-541` (`getFeedItemShareTitle`) with:
193193+194194+```go
195195+func getFeedItemShareTitle(item *feed.FeedItem) string {
196196+ if title := item.DisplayTitle(); title != "" {
197197+ return title
198198+ }
199199+ return "Arabica"
200200+}
201201+```
202202+203203+The per-type fallbacks (e.g., "Coffee Bean", "Roaster") now live in
204204+`(*FeedItem).DisplayTitle()`.
205205+206206+### Task 4: Migrate `getDeleteURL` in `feed.templ`
207207+208208+**Files:**
209209+210210+- Modify: `internal/web/pages/feed.templ`
211211+212212+**Step 1: Replace the switch**
213213+214214+Brew has an asymmetric delete URL (`/brews/{rkey}` instead of
215215+`/api/brews/{rkey}`) — leave it as an explicit branch, don't smuggle
216216+the asymmetry into the descriptor. Replace lines `564-592`
217217+(`getDeleteURL`) with:
218218+219219+```go
220220+func getDeleteURL(item *feed.FeedItem) string {
221221+ rkey := item.RKey()
222222+ if rkey == "" {
223223+ return ""
224224+ }
225225+ if item.RecordType == lexicons.RecordTypeBrew {
226226+ return fmt.Sprintf("/brews/%s", rkey)
227227+ }
228228+ if d := entities.Get(item.RecordType); d != nil {
229229+ return fmt.Sprintf("/api/%s/%s", d.URLPath, rkey)
230230+ }
231231+ return ""
232232+}
233233+```
234234+235235+**Step 2: Regenerate**
236236+237237+```bash
238238+templ generate -f internal/web/pages/feed.templ
239239+```
240240+241241+### Task 5: OG card label uses `Descriptor.Noun`
242242+243243+**Files:**
244244+245245+- Modify: `internal/ogcard/entities.go`
246246+247247+**Step 1: Replace hardcoded labels**
248248+249249+In the four simple Draw functions (`DrawBeanCard`, `DrawRoasterCard`,
250250+`DrawGrinderCard`, `DrawBrewerCard`), replace the second argument to
251251+`newTypedCard` with a descriptor lookup. Example:
252252+253253+Before:
254254+```go
255255+card, err := newTypedCard(AccentBean, "bean")
256256+```
257257+258258+After:
259259+```go
260260+card, err := newTypedCard(AccentBean, entities.Get(lexicons.RecordTypeBean).Noun)
261261+```
262262+263263+Apply to all four. Leave `DrawRecipeCard` as-is — its label is
264264+`recipeType+" recipe"` (e.g., "espresso recipe"), which is bespoke and
265265+can't be expressed by `Noun` alone.
266266+267267+`DrawBrewCard` in `ogcard/brew.go` is also bespoke (brew is a unique
268268+case across the refactor); leave it alone.
269269+270270+**Note on accent colors:** the `AccentX` constants stay in
271271+`ogcard/brew.go`. Putting `color.RGBA` on `Descriptor` would force
272272+`entities` to import `image/color`, and the colors are an OG-card
273273+implementation detail. If a future phase consolidates the Draw
274274+functions further, an `accentByType` map can live in `ogcard` itself.
275275+276276+### Task 6: Verify
277277+278278+**Commands:**
279279+280280+```bash
281281+go vet ./...
282282+go build ./...
283283+go test ./...
284284+just run
285285+```
286286+287287+**Manual smoke test:**
288288+289289+- Load `/feed`:
290290+ - Click each entity type's card — share URL should match the
291291+ `/{entity}/{rkey}?owner={did}` pattern as before
292292+ - Click the share button — title should match (e.g., bean's name)
293293+ - Click delete on each entity (without confirming) — URL should be
294294+ `/api/{entity}/{rkey}` for non-brew, `/brews/{rkey}` for brew
295295+- Hit each OG card endpoint (e.g., `/api/og/beans/{rkey}`) — labels
296296+ in the corner should still read "bean", "roaster", etc.
297297+298298+**Expected delta:**
299299+300300+- `feed.templ`: ~120 LOC removed (three switches collapsed into helpers
301301+ using descriptor + accessor methods)
302302+- `feed/service.go`: ~70 LOC added (RKey + DisplayTitle methods)
303303+- `ogcard/entities.go`: ~5 LOC changed (label arg)
304304+- Net: ~−45 LOC, plus the wins from killing three more switch sites
305305+ and proving the descriptor + container-accessor pattern composes.
306306+307307+---
308308+309309+## Out of scope for phase 1
310310+311311+These belong to later phases (see parent spec):
312312+313313+- Modal shell extraction (phase 5')
314314+- Cache typed fields → map (phase 2, deferred)
315315+- Generic store CRUD (phase 3, deferred)
316316+- View handler unification (phase 4, deferred)
317317+- Routing loop, suggestions config, dirty-tracking TTL (phase 6, deferred)
318318+- Per-entity OG card Draw consolidation (intentionally not done — see
319319+ phase 5 / FieldSpec discussion in parent spec)
···11+# Entity Descriptor — Phase 5': Modal Shell Extraction
22+33+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to
44+> implement this plan task-by-task.
55+66+**Goal:** Extract the dialog/header/error/footer scaffolding shared across
77+the five entity modals into a single `ModalShell` component. Each modal
88+keeps its own templ body for the field markup. No DSL, no FieldSpec list —
99+just a shared shell.
1010+1111+**Parent spec:** `docs/entity-descriptor-refactor.md`
1212+**Previous phase:** `docs/plans/2026-04-25-entity-descriptor-phase-1.md`
1313+1414+**Tech Stack:** Go 1.26, Templ.
1515+1616+---
1717+1818+## Major Design Changes
1919+2020+### What's actually duplicated across modals
2121+2222+After auditing all five modals, the **shell** is identical (modulo entity
2323+name and URL):
2424+2525+1. `<dialog id="entity-modal" class="modal-dialog">` open
2626+2. `<div class="modal-content">` wrapper
2727+3. `<h3 class="modal-title">` with conditional "Edit X" / "Add X"
2828+4. `<form>` with:
2929+ - Conditional `hx-put` (edit) / `hx-post` (create) URL
3030+ - Standard `hx-trigger`, `hx-swap`, `class`
3131+ - `x-data="{ serverError: '' }"` (recipe extends this with pour state)
3232+ - The big `hx-on::after-request` handler (identical, except recipe is
3333+ **missing the 401 session-expired branch** — a latent bug we'll fix
3434+ while we're here)
3535+5. `<div x-show="serverError" ...>` server error display
3636+6. **(per-entity field body — this is what stays in each modal's templ)**
3737+7. Footer `<div class="flex gap-2 pt-2">` with Save/Cancel buttons
3838+8. Close `</form></div></dialog>`
3939+4040+### What stays per-entity
4141+4242+- The field body — fieldsets, inputs, dropdowns, the bean modal's roaster
4343+ picker, the bean rating slider, the brewer modal's conditional sections,
4444+ the recipe modal's pour repeater
4545+- Per-entity Alpine state — recipe needs `pours`, `addPour()`,
4646+ `removePour()` plumbed through `ExtraXData`
4747+- The bean modal's "bag is closed" checkbox (only shown on edit)
4848+4949+### The component
5050+5151+```go
5252+type ModalShellProps struct {
5353+ Type lexicons.RecordType // looked up via entities.Get()
5454+ RKey string // "" → create (POST), non-empty → edit (PUT)
5555+ ExtraXData string // optional Alpine x-data; defaults to `{ serverError: '' }`
5656+}
5757+```
5858+5959+The shell looks up the descriptor, derives `DisplayName` and `URLPath`,
6060+builds the action URL (`/api/{URLPath}` for create, `/api/{URLPath}/{rkey}`
6161+for edit), and renders the wrapper. The body comes in as templ children.
6262+6363+### Why this beats the rejected FieldSpec design
6464+6565+- **No DSL**: each modal's body is plain templ markup you can read
6666+ top-to-bottom
6767+- **Bespoke widgets stay free**: the roaster picker, rating slider,
6868+ conditional brewer fields, recipe pour repeater all stay where they are
6969+- **Cross-cutting changes are still cheap**: changing the error display,
7070+ rewriting the after-request handler, retitling buttons — all single-edit
7171+- **LOC saved**: ~150 (5 modals × ~30 lines of shell each)
7272+- **Risk**: low. If a future modal needs a different shell, it can opt out
7373+ by not using `ModalShell`.
7474+7575+### What is NOT changing
7676+7777+- Field bodies (the entire point — they stay readable)
7878+- `getStringValue` / `recordTypeOf` (already migrated in phase 0)
7979+- Modal route registration (phase 6 work)
8080+- Modal handler functions
8181+- The `ConfirmDeleteModal` (different shape — confirmation, not edit form)
8282+8383+---
8484+8585+## Phase 5': Tasks
8686+8787+### Task 1: Create `ModalShell` component
8888+8989+**Files:**
9090+9191+- Create: `internal/web/components/modal_shell.templ`
9292+9393+**Step 1: Define the shell**
9494+9595+```templ
9696+package components
9797+9898+import (
9999+ "tangled.org/arabica.social/arabica/internal/entities"
100100+ "tangled.org/arabica.social/arabica/internal/lexicons"
101101+)
102102+103103+// ModalShellProps configures the shared dialog wrapper used by entity
104104+// create/edit modals. Body content is passed as templ children.
105105+type ModalShellProps struct {
106106+ Type lexicons.RecordType
107107+ RKey string // "" = create, non-empty = edit
108108+ ExtraXData string // optional Alpine x-data (defaults to `{ serverError: '' }`)
109109+}
110110+111111+// modalAfterRequest is the htmx after-request handler shared across
112112+// entity modals. Closes the dialog on success, surfaces session-expired
113113+// on 401, and otherwise displays a generic error in the form.
114114+const modalAfterRequest = `if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { this.closest('dialog').close(); window.__showSessionExpiredModal(); } else { this._x_dataStack[0].serverError = event.detail.xhr ? 'Something went wrong. Please try again.' : 'Connection error. Check your network.'; }`
115115+116116+func modalShellXData(extra string) string {
117117+ if extra != "" {
118118+ return extra
119119+ }
120120+ return "{ serverError: '' }"
121121+}
122122+123123+func modalActionURL(d *entities.Descriptor, rkey string) string {
124124+ if rkey != "" {
125125+ return "/api/" + d.URLPath + "/" + rkey
126126+ }
127127+ return "/api/" + d.URLPath
128128+}
129129+130130+templ ModalShell(props ModalShellProps) {
131131+ if d := entities.Get(props.Type); d != nil {
132132+ <dialog id="entity-modal" class="modal-dialog">
133133+ <div class="modal-content">
134134+ <h3 class="modal-title">
135135+ if props.RKey != "" {
136136+ Edit { d.DisplayName }
137137+ } else {
138138+ Add { d.DisplayName }
139139+ }
140140+ </h3>
141141+ <form
142142+ if props.RKey != "" {
143143+ hx-put={ modalActionURL(d, props.RKey) }
144144+ } else {
145145+ hx-post={ modalActionURL(d, "") }
146146+ }
147147+ hx-trigger="submit"
148148+ hx-swap="none"
149149+ x-data={ modalShellXData(props.ExtraXData) }
150150+ hx-on::after-request={ modalAfterRequest }
151151+ class="space-y-5"
152152+ >
153153+ <div x-show="serverError" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-800" x-text="serverError"></div>
154154+ { children... }
155155+ <div class="flex gap-2 pt-2">
156156+ <button type="submit" class="flex-1 btn-primary">Save</button>
157157+ <button
158158+ type="button"
159159+ @click="$el.closest('dialog').close()"
160160+ class="flex-1 btn-secondary"
161161+ >
162162+ Cancel
163163+ </button>
164164+ </div>
165165+ </form>
166166+ </div>
167167+ </dialog>
168168+ }
169169+}
170170+```
171171+172172+**Step 2: Generate**
173173+174174+```bash
175175+templ generate -f internal/web/components/modal_shell.templ
176176+```
177177+178178+### Task 2: Migrate `GrinderDialogModal` (proof of concept — simplest)
179179+180180+**Files:**
181181+182182+- Modify: `internal/web/components/dialog_modals.templ`
183183+184184+**Step 1: Replace shell, keep body**
185185+186186+Replace the `templ GrinderDialogModal(...)` definition. The body
187187+(everything between the form open tag and the Save button) stays
188188+verbatim, wrapped in a `@ModalShell(...) { ... }` call.
189189+190190+**Step 2: Regenerate and verify**
191191+192192+```bash
193193+templ generate -f internal/web/components/dialog_modals.templ
194194+go build ./...
195195+```
196196+197197+Open the grinder modal in the browser. Confirm: Edit/Add label, form
198198+submission, error display, footer buttons all work identically.
199199+200200+### Task 3: Migrate `RoasterDialogModal`
201201+202202+Same shape as grinder. Single edit + regenerate.
203203+204204+### Task 4: Migrate `BrewerDialogModal`
205205+206206+Same shape. The conditional espresso/pourover sections inside the body
207207+stay as-is — they're field-body concerns, not shell concerns.
208208+209209+### Task 5: Migrate `BeanDialogModal`
210210+211211+The roaster picker (~80 lines of Alpine) and rating slider (Alpine state)
212212+stay inside the body verbatim. The bean's "bag is closed" checkbox also
213213+stays in the body. Only the shell scaffolding is extracted.
214214+215215+### Task 6: Migrate `RecipeDialogModal`
216216+217217+Recipe is the special case:
218218+219219+- **Fix the latent bug**: recipe's current `hx-on::after-request` is
220220+ missing the 401 session-expired branch. Migrating to `ModalShell`
221221+ fixes it automatically (the shell uses the canonical handler).
222222+- **Plumb pour state through `ExtraXData`**: pass
223223+ `fmt.Sprintf("{ serverError: '', pours: %s, addPour() { this.pours.push({water: '', time: ''}); }, removePour(i) { this.pours.splice(i, 1); } }", recipePourJSON(recipe))`
224224+ as `ExtraXData`. The shell uses it instead of the default.
225225+226226+### Task 7: Verify
227227+228228+**Commands:**
229229+230230+```bash
231231+go vet ./...
232232+go build ./...
233233+go test ./...
234234+just run
235235+```
236236+237237+**Manual smoke test (per modal):**
238238+239239+For each of bean, grinder, brewer, roaster, recipe:
240240+241241+1. Open the create modal — title reads "Add X", submitting POSTs to `/api/{path}`
242242+2. Open the edit modal for an existing record — title reads "Edit X",
243243+ submitting PUTs to `/api/{path}/{rkey}`
244244+3. Trigger a server error (e.g. invalid input) — error display appears
245245+4. Cancel button closes the dialog
246246+5. Successful submit closes the dialog and triggers `refreshManage`
247247+248248+Bean-specific: roaster picker still works, rating slider still works.
249249+Brewer-specific: espresso/pourover conditional fields still toggle.
250250+Recipe-specific: pour add/remove still works; 401 now closes the dialog
251251+and surfaces the session-expired modal (previously didn't).
252252+253253+**Expected delta:**
254254+255255+- ~150 LOC removed from `dialog_modals.templ` (5 × ~30 lines of shell)
256256+- ~80 LOC added in `modal_shell.templ`
257257+- Net: ~−70 LOC, plus a latent recipe bug fixed and zero DSL risk.
258258+259259+---
260260+261261+## Out of scope for phase 5'
262262+263263+- Field-body abstraction or FieldSpec DSL (rejected — see parent spec)
264264+- Modal route loop (phase 6)
265265+- Suggestions config from descriptor (phase 6)
266266+- Migrating `ConfirmDeleteModal` (different shape; not part of the
267267+ create/edit family)
···11+# Entity Descriptor — Phase 6: Cleanup Pass
22+33+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to
44+> implement this plan task-by-task.
55+66+**Goal:** Three targeted cleanups that use the descriptor or remove manually
77+maintained per-entity maps. No behavior change except PublicClient latency.
88+99+**Parent spec:** `docs/entity-descriptor-refactor.md`
1010+**Previous phase:** `docs/plans/2026-04-25-entity-descriptor-phase-5.md`
1111+1212+---
1313+1414+## Scope (revised from original spec)
1515+1616+| Item | Decision | Why |
1717+|---|---|---|
1818+| `entityTypeToNSID` map in suggestions handler | ✅ do it | Descriptor owns this data; the map must be updated when cafe/drink land |
1919+| PublicClient resolver cache | ✅ do it | Small, low-risk, reduces network calls in firehose hot path |
2020+| Modal route loop | ✅ do it (minor) | Removes 5 repetitive `HandleFunc` pairs |
2121+| Dirty-tracking → TTL | ❌ defer | Cache correctness concern; belongs to a future phase 2 |
2222+| Suggestions `entityConfigs` from descriptor | ❌ skip | Dedup functions and field lists are suggestions-specific, not descriptor data |
2323+2424+---
2525+2626+## Task 1: Replace `entityTypeToNSID` with descriptor lookup
2727+2828+**Files:**
2929+3030+- Modify: `internal/handlers/suggestions.go`
3131+3232+**Current:**
3333+3434+```go
3535+var entityTypeToNSID = map[string]string{
3636+ "roasters": atproto.NSIDRoaster,
3737+ "grinders": atproto.NSIDGrinder,
3838+ "brewers": atproto.NSIDBrewer,
3939+ "beans": atproto.NSIDBean,
4040+ "recipes": atproto.NSIDRecipe,
4141+}
4242+```
4343+4444+**After:** Build the map from the descriptor registry so new entities
4545+(cafe, drink) appear automatically when registered.
4646+4747+```go
4848+var entityTypeToNSID = func() map[string]string {
4949+ m := make(map[string]string)
5050+ for _, d := range entities.All() {
5151+ if d.NSID != "" {
5252+ m[d.URLPath] = d.NSID
5353+ }
5454+ }
5555+ return m
5656+}()
5757+```
5858+5959+Remove the `atproto` import from this file if it becomes unused.
6060+6161+## Task 2: Compact modal route registration
6262+6363+**Files:**
6464+6565+- Modify: `internal/routing/routing.go`
6666+6767+**Current:** 10 individual `HandleFunc` calls for 5 entity modals (new + edit each).
6868+6969+**After:** Loop over a compact slice of `{noun, new handler, edit handler}`:
7070+7171+```go
7272+for _, m := range []struct {
7373+ noun string
7474+ new http.HandlerFunc
7575+ edit http.HandlerFunc
7676+}{
7777+ {"bean", h.HandleBeanModalNew, h.HandleBeanModalEdit},
7878+ {"grinder", h.HandleGrinderModalNew, h.HandleGrinderModalEdit},
7979+ {"brewer", h.HandleBrewerModalNew, h.HandleBrewerModalEdit},
8080+ {"roaster", h.HandleRoasterModalNew, h.HandleRoasterModalEdit},
8181+ {"recipe", h.HandleRecipeModalNew, h.HandleRecipeModalEdit},
8282+} {
8383+ mux.HandleFunc("GET /api/modals/"+m.noun+"/new", m.new)
8484+ mux.HandleFunc("GET /api/modals/"+m.noun+"/{id}", m.edit)
8585+}
8686+```
8787+8888+Note: handler references can't come from the descriptor (they're business
8989+logic, not metadata), so this still has 5 data entries. The win is that
9090+the pattern is explicit and each entity is one line.
9191+9292+## Task 3: Cache PublicClient resolver results
9393+9494+**Files:**
9595+9696+- Modify: `internal/atproto/public_client.go`
9797+9898+**Current:** `GetPDSEndpoint` and `ResolveHandle` make raw network calls
9999+with no caching. In the firehose processing path these can be called
100100+repeatedly for the same DID or handle.
101101+102102+**After:** Add a simple TTL cache (1 hour) using `sync.RWMutex` + a map of
103103+`cachedValue{value string, expiry time.Time}`. Cache miss falls through to
104104+the existing inner client call. No change to the public API.
105105+106106+```go
107107+type cachedValue struct {
108108+ value string
109109+ expiry time.Time
110110+}
111111+112112+type PublicClient struct {
113113+ inner *atp.PublicClient
114114+ pdsMu sync.RWMutex
115115+ pdsCache map[string]cachedValue // DID → PDS URL
116116+ handleMu sync.RWMutex
117117+ handleCache map[string]cachedValue // handle → DID
118118+}
119119+120120+const resolverCacheTTL = time.Hour
121121+```
122122+123123+Keep it simple: no explicit invalidation, just TTL expiry. A 1-hour TTL
124124+means stale PDS endpoints are served for at most an hour, which is
125125+acceptable (PDS migrations are rare).
126126+127127+## Task 4: Verify
128128+129129+```bash
130130+go vet ./...
131131+go build ./...
132132+go test ./...
133133+```
134134+135135+Check: suggestions endpoint still returns results for bean/grinder/brewer/
136136+roaster/recipe. Modal routes still open and submit correctly.
137137+138138+---
139139+140140+## Out of scope
141141+142142+- Dirty-tracking → TTL (cache correctness; deferred to phase 2)
143143+- Suggestions `entityConfigs` from descriptor (field lists and dedup
144144+ functions are suggestions-specific; descriptor is the wrong home)
145145+- Any new entity registrations (cafe/drink) — those belong to feature work
+51-3
internal/atproto/public_client.go
···33import (
44 "context"
55 "net/http"
66+ "sync"
67 "time"
7889 "tangled.org/pdewey.com/atp"
···1415// so existing callers continue to work without changes.
1516type Profile = atp.PublicProfile
16171818+const resolverCacheTTL = time.Hour
1919+2020+type cachedValue struct {
2121+ value string
2222+ expiry time.Time
2323+}
2424+1725// PublicClient wraps atp.PublicClient and exposes the same method signatures
1826// that arabica callers already use (GetRecord, ListRecords, etc.).
1927type PublicClient struct {
2028 inner *atp.PublicClient
2929+3030+ pdsMu sync.RWMutex
3131+ pdsCache map[string]cachedValue // DID → PDS URL
3232+3333+ handleMu sync.RWMutex
3434+ handleCache map[string]cachedValue // handle → DID
2135}
22362337// NewPublicClient creates a PublicClient with OTel-instrumented HTTP transport.
···2640 Timeout: 30 * time.Second,
2741 Transport: &userAgentTransport{base: otelhttp.NewTransport(http.DefaultTransport)},
2842 }
2929- return &PublicClient{inner: atp.NewPublicClientWithHTTP(hc)}
4343+ return &PublicClient{
4444+ inner: atp.NewPublicClientWithHTTP(hc),
4545+ pdsCache: make(map[string]cachedValue),
4646+ handleCache: make(map[string]cachedValue),
4747+ }
3048}
31493250// GetPDSEndpoint resolves a DID to the user's PDS base URL.
3351func (c *PublicClient) GetPDSEndpoint(ctx context.Context, did string) (string, error) {
3434- return c.inner.GetPDSEndpoint(ctx, did)
5252+ c.pdsMu.RLock()
5353+ if v, ok := c.pdsCache[did]; ok && time.Now().Before(v.expiry) {
5454+ c.pdsMu.RUnlock()
5555+ return v.value, nil
5656+ }
5757+ c.pdsMu.RUnlock()
5858+5959+ url, err := c.inner.GetPDSEndpoint(ctx, did)
6060+ if err != nil {
6161+ return "", err
6262+ }
6363+6464+ c.pdsMu.Lock()
6565+ c.pdsCache[did] = cachedValue{value: url, expiry: time.Now().Add(resolverCacheTTL)}
6666+ c.pdsMu.Unlock()
6767+ return url, nil
3568}
36693770// GetProfile fetches a user's public profile by DID or handle.
···41744275// ResolveHandle resolves an AT Protocol handle to a DID.
4376func (c *PublicClient) ResolveHandle(ctx context.Context, handle string) (string, error) {
4444- return c.inner.ResolveHandle(ctx, handle)
7777+ c.handleMu.RLock()
7878+ if v, ok := c.handleCache[handle]; ok && time.Now().Before(v.expiry) {
7979+ c.handleMu.RUnlock()
8080+ return v.value, nil
8181+ }
8282+ c.handleMu.RUnlock()
8383+8484+ did, err := c.inner.ResolveHandle(ctx, handle)
8585+ if err != nil {
8686+ return "", err
8787+ }
8888+8989+ c.handleMu.Lock()
9090+ c.handleCache[handle] = cachedValue{value: did, expiry: time.Now().Add(resolverCacheTTL)}
9191+ c.handleMu.Unlock()
9292+ return did, nil
4593}
46944795// PublicListRecordsOutput represents the response from public listRecords API.
+49
internal/entities/entities.go
···11+// Package entities provides a registry of descriptors for each Arabica record
22+// type. A descriptor captures the per-entity data that callers in feed, templ,
33+// handlers, and ogcard dispatch on, replacing scattered switch statements with
44+// a single lookup.
55+package entities
66+77+import (
88+ "fmt"
99+ "sort"
1010+1111+ "tangled.org/arabica.social/arabica/internal/lexicons"
1212+)
1313+1414+// Descriptor describes one Arabica record type.
1515+type Descriptor struct {
1616+ Type lexicons.RecordType
1717+ NSID string
1818+ DisplayName string // "Bean"
1919+ Noun string // "bean" — appears in copy: "added a new bean"
2020+ URLPath string // "beans" — share URLs and routes
2121+2222+ // GetField extracts one named string field from a typed model pointer for
2323+ // form prefill. Returns ("", false) if entity is nil or field is unknown.
2424+ GetField func(entity any, field string) (string, bool)
2525+}
2626+2727+var registry = map[lexicons.RecordType]*Descriptor{}
2828+2929+// Register adds a descriptor. Called once per entity at package init.
3030+// Panics on duplicate registration to catch wiring bugs at startup.
3131+func Register(d *Descriptor) {
3232+ if _, ok := registry[d.Type]; ok {
3333+ panic(fmt.Sprintf("entities: duplicate descriptor for %s", d.Type))
3434+ }
3535+ registry[d.Type] = d
3636+}
3737+3838+// Get returns the descriptor for a record type, or nil if unregistered.
3939+func Get(rt lexicons.RecordType) *Descriptor { return registry[rt] }
4040+4141+// All returns descriptors in stable order (by RecordType). Use for route loops.
4242+func All() []*Descriptor {
4343+ out := make([]*Descriptor, 0, len(registry))
4444+ for _, d := range registry {
4545+ out = append(out, d)
4646+ }
4747+ sort.Slice(out, func(i, j int) bool { return out[i].Type < out[j].Type })
4848+ return out
4949+}