# Flexible Entity Relationship System ## Context The rose-ash platform has 9 microservices with decoupled entities (posts, pages, markets, calendars, entries, products, etc.) connected via a generic `ContainerRelation` table. The current system is limited: - **Parent-child only** — no cardinality, no relation types, no many-to-many - **Feature flags gate everything** — `PageConfig.features["market"]=true` must be set before creating a market on a page - **Navigation is disconnected** — `rebuild_navigation()` is a no-op; MenuNode is manually managed - **Per-domain boilerplate** — each service has its own container-nav fragment handler, its own create-and-attach flow - **No relation semantics** — you can't distinguish *how* two entities are connected, only *that* they are The s-expression engine (phases 1-7 complete) gives us a declarative language to define relations and auto-generate UI. Building this now means the remaining sexp work gets designed around the relation model. **Goal**: A registry-driven system where relation types are declared in s-expressions, cardinality is enforced, navigation is auto-generated, and attach/detach is generic. --- ## Key Decisions 1. **Relations is a graph store, not an orchestrator.** Domain services create their own entities, then call `relate` on the relations service. Relations validates cardinality and stores the link. It never calls out to domain services to create entities. 2. **All entity connections live in ContainerRelation.** Including many-to-many (e.g. post↔calendar_entry). Single source of truth. Domain-specific junction tables (like `calendar_entry_posts`) migrate here. ## Architecture: Three Layers ``` ┌─────────────────────────────────────────────────────────┐ │ RELATION REGISTRY (shared/sexp/relations.py) │ │ defrelation declarations — loaded at startup by all │ │ services from shared code. Pure data, no DB. │ ├─────────────────────────────────────────────────────────┤ │ RELATION STORAGE (relations service, db_relations) │ │ container_relations table + relation_type column. │ │ Graph store. Validates against registry. │ │ Domain services create entities, then call relate. │ ├─────────────────────────────────────────────────────────┤ │ RELATION CONSUMERS (all services) │ │ Nav auto-generation, UI components, fragment rendering │ │ Read registry to know how to display/manage relations │ └─────────────────────────────────────────────────────────┘ ``` --- ## Phase A: Relation Registry ### A.1 — `defrelation` s-expression form New special form for the evaluator, alongside `defcomp`: ```scheme (defrelation :page->market :from "page" :to "market" :cardinality :one-to-many :inverse :market->page :nav :submenu :nav-icon "fa fa-shopping-bag" :nav-label "markets") (defrelation :page->calendar :from "page" :to "calendar" :cardinality :one-to-many :inverse :calendar->page :nav :submenu :nav-icon "fa fa-calendar" :nav-label "calendars") (defrelation :post->calendar_entry :from "post" :to "calendar_entry" :cardinality :many-to-many :inverse :calendar_entry->post :nav :inline :nav-icon "fa fa-file-alt" :nav-label "events") (defrelation :page->menu_node :from "page" :to "menu_node" :cardinality :one-to-one :nav :hidden) ``` ### A.2 — RelationDef type **File: `shared/sexp/types.py`** — add alongside `Component`, `Lambda`: ```python @dataclass(frozen=True) class RelationDef: name: str # "page->market" from_type: str # "page" to_type: str # "market" cardinality: str # "one-to-one" | "one-to-many" | "many-to-many" inverse: str | None # "market->page" nav: str # "submenu" | "tab" | "badge" | "inline" | "hidden" nav_icon: str | None # "fa fa-shopping-bag" nav_label: str | None # "markets" — display label for nav sections ``` ### A.3 — Registry module **New file: `shared/sexp/relations.py`** ```python _RELATION_REGISTRY: dict[str, RelationDef] = {} def load_relation_registry() -> None: """Parse defrelation s-expressions, populate registry.""" for source in [_PAGE_MARKET, _PAGE_CALENDAR, _POST_ENTRY, _PAGE_MENU_NODE]: register_relations(source) def get_relation(name: str) -> RelationDef | None: ... def relations_from(entity_type: str) -> list[RelationDef]: ... def relations_to(entity_type: str) -> list[RelationDef]: ... ``` Called from `create_base_app()` at startup alongside `load_shared_components()`. ### A.4 — `defrelation` in the evaluator **File: `shared/sexp/evaluator.py`** — add to `_SPECIAL_FORMS`: Parses keyword args from the s-expression, creates a `RelationDef`, stores it in `_RELATION_REGISTRY` and the current env. ### A.5 — Replaces PageConfig feature flags - The existence of `defrelation :page->market` means pages *can* have markets. No feature flag needed. - Admin UI queries `relations_from("page")` to show create buttons. - `PageConfig` survives only for SumUp payment credentials. The `features` JSON column is deprecated. ### A.6 — Tests **New file: `shared/sexp/tests/test_relations.py`** - Parse `defrelation`, verify `RelationDef` fields - Registry lookup: `get_relation()`, `relations_from()`, `relations_to()` - Cardinality values validated - Inverse relation lookup ### Files touched - `shared/sexp/types.py` — add `RelationDef` - `shared/sexp/evaluator.py` — add `defrelation` special form - `shared/sexp/relations.py` — NEW: registry + definitions - `shared/sexp/tests/test_relations.py` — NEW: registry tests - `shared/infrastructure/factory.py` — call `load_relation_registry()` at startup --- ## Phase B: Schema Evolution ### B.1 — Add `relation_type` column **File: `shared/models/container_relation.py`** ```python relation_type: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) ``` New composite index: `(relation_type, parent_type, parent_id)` for filtered child queries. ### B.2 — Alembic migration **File: `relations/alembic/versions/xxx_add_relation_type.py`** 1. Add nullable `relation_type` column 2. Add indexes 3. Backfill existing rows: - `(page, *, market, *)` → `relation_type = "page->market"` - `(page, *, calendar, *)` → `relation_type = "page->calendar"` - `(page, *, menu_node, *)` → `relation_type = "page->menu_node"` ### B.3 — Add `get_parents()` query **File: `shared/services/relationships.py`** Inverse of `get_children()` — query by `(child_type, child_id)` with optional `relation_type` filter. Uses existing `ix_container_relations_child` index. ### B.4 — Update `attach_child()` / `detach_child()` Add optional `relation_type` parameter. When provided: - Stored in the relation row - Included in emitted activity `object_data` - Used for cardinality enforcement (Phase C) Existing callers without `relation_type` continue to work (backward compatible). ### Files touched - `shared/models/container_relation.py` — add column + index - `shared/services/relationships.py` — add `relation_type` param, add `get_parents()` - `relations/alembic/versions/` — NEW migration --- ## Phase C: Generic Relate/Unrelate API ### C.1 — New action endpoints on relations service **File: `relations/bp/actions/routes.py`** **`relate`** — validates against registry, enforces cardinality, delegates to `attach_child()`: ```json POST /internal/actions/relate { "relation_type": "page->market", "from_id": 42, "to_id": 7, "label": "Farm Shop", "metadata": {"slug": "farm-shop"} } ``` **`unrelate`** — validates, delegates to `detach_child()`: ```json POST /internal/actions/unrelate { "relation_type": "page->market", "from_id": 42, "to_id": 7 } ``` **`can-relate`** — pre-flight check (cardinality, registry validation) without creating anything: ```json POST /internal/actions/can-relate { "relation_type": "page->market", "from_id": 42 } → {"allowed": true} or {"allowed": false, "reason": "one-to-one already exists"} ``` Domain services call `can-relate` *before* creating the entity to avoid orphans on cardinality failure. ### C.2 — Typical domain flow ``` 1. Domain service receives "create market on page 42" request 2. call_action("relations", "can-relate", {relation_type: "page->market", from_id: 42}) 3. If not allowed → return error to user (no entity created) 4. Create the entity locally (market service creates MarketPlace row) 5. call_action("relations", "relate", {relation_type: "page->market", from_id: 42, to_id: 7, ...}) ``` ### C.3 — Cardinality enforcement In the `relate` and `can-relate` handlers: - **one-to-one**: Check no active relation of this type exists for `from_id`. Reject if found. - **one-to-many**: No limit (current behavior). - **many-to-many**: No limit in either direction. ### C.4 — Enhanced activity emission Activities now include `relation_type` in `object_data`: ```python object_data={ "relation_type": "page->market", "parent_type": "page", "parent_id": 42, "child_type": "market", "child_id": 7, } ``` ### C.5 — Existing endpoints stay as aliases `attach-child` and `detach-child` remain during transition. They infer `relation_type` from `(parent_type, child_type)` when not provided. ### Files touched - `relations/bp/actions/routes.py` — add `relate`, `unrelate`, `can-relate` - `shared/services/relationships.py` — cardinality check in `attach_child()` --- ## Phase D: Navigation Auto-Generation ### D.1 — Generic container-nav fragment **New handler: `relations/bp/fragments/routes.py`** The relations service becomes a fragment provider. A single generic handler replaces per-service container-nav handlers in market and events: ```python async def _container_nav_handler(): container_type = request.args["container_type"] container_id = int(request.args["container_id"]) post_slug = request.args.get("post_slug", "") nav_defs = [d for d in relations_from(container_type) if d.nav != "hidden"] parts = [] for defn in nav_defs: children = await get_children( g.s, container_type, container_id, child_type=defn.to_type, relation_type=defn.name, ) for child in children: parts.append(render_sexp( '(~relation-nav :href href :name name :icon icon :nav-class nc)', href=_build_href(defn, child, post_slug, ctx), name=child.label or "", icon=defn.nav_icon or "", nc=nav_class, )) return "\n".join(parts) ``` ### D.2 — Nav hints | `nav` value | Behavior | |-------------|----------| | `submenu` | Clickable item in container-nav header/sidebar | | `tab` | Tab in a tabbed interface | | `badge` | Count badge on parent | | `inline` | Inline within parent content | | `hidden` | Not shown in navigation | ### D.3 — `rebuild_navigation()` becomes real **File: `shared/services/navigation.py`** For now: invalidate nav caches when relations change. The top-level MenuNode entries (depth=0, main nav bar) remain manually managed. Child navigation (markets, calendars under a page) is generated dynamically from the relation registry via the generic container-nav fragment. Future: `rebuild_navigation()` could materialize the full nav tree from the relation graph for performance. ### D.4 — Href computation The `ContainerRelation.label` column stores the display name. Add a `metadata` JSON column for additional denormalized data (slug, icon) needed to compute hrefs without fetching from the owner service: ```python metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) # e.g., {"slug": "farm-shop", "icon": "..."} ``` The `relate` action populates this from the caller's payload. ### Files touched - `relations/bp/fragments/routes.py` — NEW: generic container-nav - `shared/services/navigation.py` — cache invalidation - `shared/models/container_relation.py` — add `metadata` JSON column - `relations/alembic/versions/` — migration for metadata column - Blog, market, events fragment routes — deprecate per-service container-nav --- ## Phase E: S-Expression UI Components ### E.1 — `~relation-nav` component **File: `shared/sexp/components.py`** — replaces `~market-link-nav`, `~calendar-link-nav`: ```scheme (defcomp ~relation-nav (&key href name icon nav-class relation-type) (a :href href :class (or nav-class "...") (when icon (i :class icon :aria-hidden "true")) (div name))) ``` ### E.2 — `~relation-attach` component Generic "add child" button driven by registry: ```scheme (defcomp ~relation-attach (&key create-url label icon) (a :href create-url :hx-get create-url :hx-target "#main-panel" :hx-swap "outerHTML" :hx-push-url "true" :class "flex items-center gap-2 text-sm p-2 rounded hover:bg-stone-100" (when icon (i :class icon)) (span (or label "Add")))) ``` ### E.3 — `~relation-detach` component Confirm-and-remove button: ```scheme (defcomp ~relation-detach (&key detach-url name) (button :hx-delete detach-url :hx-confirm (str "Remove " name "?") :class "text-red-500 hover:text-red-700 text-sm" (i :class "fa fa-times"))) ``` ### E.4 — Keep specializations where needed `~calendar-entry-nav` has date display logic — keep it as a specialization. The generic handler can delegate to specific components based on `to_type` when richer rendering is needed. ### Files touched - `shared/sexp/components.py` — add `~relation-nav`, `~relation-attach`, `~relation-detach` --- ## Phase F: Migration of Existing Relations ### F.1 — Migration order 1. Add `relation_type` column + backfill (Phase B) 2. Deploy registry + new endpoints (Phases A, C) 3. Switch callers one-by-one to use `can-relate` + `relate`: - `shared/services/market_impl.py` — market creation: check can-relate, create, relate - `events/bp/calendars/services/calendars.py` — calendar creation - `blog/bp/menu_items/services/menu_items.py` — menu node creation 4. Deploy generic container-nav fragment (Phase D) 5. Switch fragment consumers from per-service to generic 6. Deprecate per-service container-nav handlers 7. Deprecate `PageConfig.features` column ### F.2 — CalendarEntryPost migration (many-to-many) The `calendar_entry_posts` junction table in db_events is a domain-specific many-to-many. Strategy: - Add `defrelation :post->calendar_entry` with `:cardinality :many-to-many` - Migrate existing rows to `ContainerRelation` with `relation_type="post->calendar_entry"` - Update `shared/services/entry_associations.py` to use `relate`/`unrelate` - Eventually drop `calendar_entry_posts` table ### F.3 — PageConfig simplification - Remove `features` JSON column (after all feature-flag checks are replaced by registry lookups) - Keep `PageConfig` for SumUp credentials only - Consider renaming to `PaymentConfig` ### Files touched (incremental) - `shared/services/market_impl.py` — use `relate` - `events/bp/calendars/services/calendars.py` — use `relate` - `blog/bp/menu_items/services/menu_items.py` — use `relate` - `shared/services/entry_associations.py` — use `relate`/`unrelate` - `blog/bp/post/services/markets.py` — simplify (no feature flag check) - `market/bp/fragments/routes.py` — deprecate container-nav - `events/bp/fragments/routes.py` — deprecate container-nav --- ## Verification ### Unit tests - `shared/sexp/tests/test_relations.py` — registry parsing, lookup, cardinality rules - `shared/sexp/tests/test_evaluator.py` — `defrelation` form evaluation - `shared/sexp/tests/test_components.py` — `~relation-nav`, `~relation-attach` rendering ### Integration tests - Relations service: `relate` with valid/invalid types, cardinality enforcement - `can-relate` pre-flight: returns allowed/denied correctly - `unrelate`: soft-deletes, emits Remove activity - Generic container-nav fragment: returns correct HTML for various relation types ### Manual testing - Browse site with Playwright after each phase - Verify nav renders correctly - Test create/delete flows for markets and calendars - Check activity bus still fires on relation changes --- ## Phase Order & Dependencies ``` Phase A (Registry) ──────┐ ├──▶ Phase C (API) ──▶ Phase D (Nav) ──▶ Phase F (Migration) Phase B (Schema) ───────┘ ▲ │ Phase E (UI Components) ``` A and B can run in parallel. C needs both. D and E need C. F is incremental, runs alongside D and E.