Closes Step 10 (10a discovery + 10b webfinger + 10c fetch). New
next/kernel/discovery_fetch.erl produces a 1-arity FetchFn closure
suitable for peer_actors:lookup_or_fetch_srv/2, completing the
discovery half that Step 5c's peer_actors cache stubbed out.
discovery_fetch API:
make_fetch_fn(Cfg) -> fun((PeerId) -> {ok, AS} | {error, _})
fetch(Url, Cfg) -> {ok, AS} | {error, _}
actor_doc_url(BaseUrl, PeerAtom) -> <Base>/actors/<peer>
accept_header/0 -> <<"application/vnd.fed-sx.actor-doc">>
decode_body(Body) -> {ok, AS} | {error, bad_actor_doc}
Closure GETs <base>/actors/<peer> via the Step 8e BIF with
Accept = application/vnd.fed-sx.actor-doc, decodes the response
body via term_codec:decode/1, returns the peer-actor-state
proplist (currently [{public_keys, [...]}]) in the shape
envelope:verify_signature consumes.
Cfg reuses dispatch_http's :peer_url / :peer_url_fn resolution so
a single Cfg threads through both delivery (8f) and discovery (10c).
Server side: http_server.erl extended to serve the same MIME.
- accept_format/1 matches application/vnd.fed-sx.actor-doc first
via the new actor_doc_prefix/0 — content negotiation atom is
`actor_doc`.
- content_type_for(actor_doc) emits the MIME on outbound.
- actor_doc_response_for/3 kernel-aware arm: with kernel + actor
-> 200 + term_codec:encode of nx_kernel:state_for/1 result.
Unknown actor -> not_found_response/0. Other formats fall
through to the existing /2 stub variants.
- actor_get/3 route dispatch threads Cfg to the /3 arm.
Port quirks documented:
* This Erlang doesn't support Mod:Fun(X) dispatch on a variable
module — kernel_actor_state/2 hardcodes nx_kernel; the Cfg
:kernel field is just a "no kernel wired" -> nil flag.
* nx_kernel:actor_state/1 is the LEGACY single-bucket accessor
that takes State (not ActorId); the server-side variant we
want is state_for/1 (gen_server:call wrapper). Easy mismatch,
documented in the comment.
Outcome mapping:
2xx + decodable body -> {ok, AS}
2xx + bad body -> {error, bad_actor_doc}
non-2xx -> {error, {status, N}}
resolver miss -> {error, no_peer_url}
transport -> {error, Reason} (BIF re-raises)
Test: next/tests/discovery_fetch.sh 11/11
Server side (in-process via http_server:actor_doc_response_for):
- Accept negotiation
- kernel + actor -> 200 + decodable body w/ :public_keys
- unknown actor -> 404
Closure side (live HTTP against background python stub returning
hand-crafted term_codec bytes):
- URL construction <base>/actors/X
- fetch live -> {ok, AS}
- make_fetch_fn closure -> {ok, AS} via static :peer_url map
- missing peer -> {error, no_peer_url}
- 404 path -> {error, {status, 404}}
- peer_actors:lookup_or_fetch/3 caches the result
Test setup note: Python term_codec encoder uses ELEMENT COUNT
(not byte length) for l/t headers — see encode/1 in term_codec.erl
which does integer_to_list(length(T)). Easy bug, documented in the
test's python source.
No-regression gates green: Erlang conformance 761/761,
httpc_request 10/10, dispatch_http 10/10, http_listen_bif 5/5,
peer_actors 19/19, discovery 12/12, http_accept 13/13,
http_actors 13/13.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rose Ash
Monorepo for the Rose Ash cooperative platform — six Quart microservices sharing a common infrastructure layer, a single PostgreSQL database, and an ActivityPub federation layer.
Services
| Service | URL | Description |
|---|---|---|
| blog | blog.rose-ash.com | Content management, Ghost sync, navigation, editor |
| market | market.rose-ash.com | Product listings, scraping, market pages |
| cart | cart.rose-ash.com | Shopping cart, checkout, orders, SumUp payments |
| events | events.rose-ash.com | Calendar, event entries, container widgets |
| federation | federation.rose-ash.com | OAuth2 authorization server, ActivityPub hub, social features |
| account | account.rose-ash.com | User dashboard, newsletters, tickets, bookings |
All services are Python 3.11 / Quart apps served by Hypercorn, deployed as a Docker Swarm stack.
Repository structure
rose-ash/
├── shared/ # Common code: models, services, infrastructure, templates
│ ├── models/ # Canonical SQLAlchemy ORM models (all domains)
│ ├── services/ # Domain service implementations + registry
│ ├── contracts/ # DTOs, protocols, widget contracts
│ ├── infrastructure/ # App factory, OAuth, ActivityPub, fragments, Jinja setup
│ ├── templates/ # Shared base templates and partials
│ ├── static/ # Shared CSS, JS, images
│ ├── editor/ # Prose editor (Node build, blog only)
│ └── alembic/ # Database migrations
├── blog/ # Blog app
├── market/ # Market app
├── cart/ # Cart app
├── events/ # Events app
├── federation/ # Federation app
├── account/ # Account app
├── docker-compose.yml # Swarm stack definition
├── deploy.sh # Local build + restart script
├── .gitea/workflows/ # CI: build changed apps + deploy
├── _config/ # Runtime config (app-config.yaml)
├── schema.sql # Reference schema snapshot
└── .env # Environment variables (not committed)
Each app follows the same layout:
{app}/
├── app.py # App entry point (creates Quart app)
├── path_setup.py # Adds project root + app dir to sys.path
├── entrypoint.sh # Container entrypoint (wait for DB, run migrations, start)
├── Dockerfile # Build instructions (monorepo context)
├── bp/ # Blueprints (routes, handlers)
│ └── fragments/ # Fragment endpoints for cross-app composition
├── models/ # Re-export stubs pointing to shared/models/
├── services/ # App-specific service wiring
├── templates/ # App-specific templates (override shared/)
└── config/ # App-specific config
Key architecture patterns
Shared models — All ORM models live in shared/models/. Each app's models/ directory contains thin re-export stubs. factory.py imports all six apps' models at startup so SQLAlchemy relationship references resolve across domains.
Service contracts — Apps communicate through typed protocols (shared/contracts/protocols.py) and frozen dataclass DTOs (shared/contracts/dtos.py), wired via a singleton registry (shared/services/registry.py). No direct HTTP calls between apps for domain logic.
Fragment composition — Apps expose HTML fragments at /internal/fragments/<type> for cross-app UI composition. The blog fetches cart, account, navigation, and event fragments to compose its pages. Fragments are cached in Redis with short TTLs.
OAuth SSO — Federation is the OAuth2 authorization server. All other apps are OAuth clients with per-app first-party session cookies (Safari ITP compatible). Login/callback/logout routes are auto-registered via shared/infrastructure/oauth.py.
ActivityPub — Each app has its own AP actor (virtual projection of the same keypair). The federation app is the social hub (timeline, compose, follow, notifications). Activities are emitted to ap_activities table and processed by EventProcessor.
Development
Quick deploy (skip CI)
# Rebuild + restart one app
./deploy.sh blog
# Rebuild + restart multiple apps
./deploy.sh blog market
# Rebuild all
./deploy.sh --all
# Auto-detect changes from git
./deploy.sh
Full stack deploy
source .env
docker stack deploy -c docker-compose.yml coop
Build a single app image
docker build -f blog/Dockerfile -t registry.rose-ash.com:5000/blog:latest .
Run migrations
Migrations run automatically on the blog service startup when RUN_MIGRATIONS=true is set (only blog runs migrations; all other apps skip them).
# Manual migration
docker exec -it $(docker ps -qf name=coop_blog) bash -c "cd shared && alembic upgrade head"
CI/CD
A single Gitea Actions workflow (.gitea/workflows/ci.yml) handles all six apps:
- Detects which files changed since the last deploy
- If
shared/ordocker-compose.ymlchanged, rebuilds all apps - Otherwise rebuilds only apps with changes (or missing images)
- Pushes images to the private registry
- Runs
docker stack deployto update the swarm
Required secrets
| Secret | Value |
|---|---|
DEPLOY_SSH_KEY |
Private SSH key for root access to the deploy host |
DEPLOY_HOST |
Hostname or IP of the deploy server |
Infrastructure
- Runtime: Python 3.11, Quart (async Flask), Hypercorn
- Database: PostgreSQL 16 (shared by all apps)
- Cache: Redis 7 (page cache, fragment cache, sessions)
- Orchestration: Docker Swarm
- Registry:
registry.rose-ash.com:5000 - CI: Gitea Actions
- Reverse proxy: Caddy (external, not in this repo)