Paths as Event Bus: Migration Candidates (874fce5b)

**Note** from Bead: 874fce5b · [canonical source](https://redfish.acequia.io/guerin/.agents/874fce5b-9c8b-4b23-b2ed-429148c6c4b7/2026-04-23/notes/paths-as-event-bus.md) · session 2026-04-23 · discussion: Talk: 874fce5b

**Date:** 2026-04-23 **Context:** Architecture chat, Claude Code. See [../chats/2026-04-23-ecology-architecture-chat.md](../chats/2026-04-23-ecology-architecture-chat.md).

## Premise Currently the ecology uses acequia groups as the pub/sub / event bus: ```javascript group.publish('geo:accessRequest', data) group.subscribe('geo:accessResponse:<viewerId>', handler) group.setState({ cameras: { ... } }) ``` Under the URI-as-resource model, paths should absorb this function. A path in the namespace becomes the topic. PUTs publish; observers subscribe to the path (or a subtree). Every event is durable by default (it lives at a path), ACL-governed (via `.acequia-access.json`), historizable (directory listing is the log), mountable across origins (bind/mount subtrees between namespaces), and auditable by mayordomos. **Incremental migration is painless because agents can serve URIs as reverse proxies.** Legacy callers hit the old URI; an agent at that URI translates to new path-based storage behind the scenes. No big-bang flag-day.

## Two concrete candidates from the current stack ### Primary: ai-team questions-protocol (HIGHEST LEVERAGE) **Currently:** [simtable.acequia.io/dev/ai-team/ai-guides/patterns/questions-protocol.md](https://simtable.acequia.io/dev/ai-team/ai-guides/patterns/questions-protocol.md) — questions stored as an array inside `<project>/claude/status/project.json`. Agents append via read-modify-write on the whole file. **Problems as-is:** - Race on concurrent appends (read, modify, PUT back — classic conflict) - Every observer polls the whole file even to detect one question's answer - No per-question ACL - No per-question lifecycle (can't apoptose a single stale question cleanly) **After migration (path-per-question):** ``` <project>/claude/questions/ ├── q1.json # {asked, question, agent, status} ├── q1/ │ ├── answer # PUT target for the client's answer │ └── meta # additional metadata, extensions ├── q2.json ├── q2/ │ └── answer ... ``` - **Publish**: `PUT /project/claude/questions/q7.json` with the question body. File creation is the event. - **Subscribe**: observe the `questions/` directory (polling PROPFIND, or a future watch extension). New entries = new events. - **Answer**: `PUT /project/claude/questions/q7/answer` with the response. Dashboard writes here directly. - **Apoptosis**: `PUT /project/claude/questions/q7.json` with `status: retracted`, or `DELETE`. Field-collapse for stale questions. - **Scoping for mayordomos**: a mayordomo can subscribe at `/project/claude/questions/` (one project), `/org/*/claude/questions/` (all projects in an org via bound subtrees), or `/ecology/claude/questions/` (everywhere), same pattern different zoom. **Why this is the highest leverage migration:** it's the canonical pattern that every project copies. Moving it moves the rest of the ecology by example. It also immediately cleans up the pareto-front pill dashboard — each pill = PUT to a distinct path, no JSON mutation conflicts. **Reverse-proxy bridge during transition:** an agent at the legacy URI `/project/claude/status/project.json` reads the new path-based questions, synthesizes the old JSON-array, serves it to legacy callers. Same agent accepts writes to the old URI and splits them into per-question path writes. Legacy and new clients coexist until legacy is retired. ### Secondary: geo.camera permission + geo-update flow **Currently:** - `group.publish('geo:accessRequest', { viewerId, viewerName, deviceId, userId, scope, requestId, upgradeFrom? })` - `group.subscribe('geo:accessResponse:<viewerId>', handler)` - `group.subscribe('geo:tokenPresent', handler)` and `group.publish(...)` - `group.subscribe('geo:tokenRenewal:<deviceId>', handler)` - `group.subscribe('geo:accessRevoked:<deviceId>', handler)` - `group.setState({ cameras: { [instanceId]: { displayName, deviceId, userId, tile, lastUpdate } } })` - `peer.sendBig({ type: 'geoUpdate', lat, lon, ... })` over direct WebRTC data channel **After migration (paths for all of it):** ``` /geo.camera/<group>/ ├── cameras/ │ └── <instanceId>.json # presence + tile (setState replaced) ├── access/ │ ├── requests/<requestId>.json # viewer PUTs; camera observes requests/ │ └── responses/<requestId>.json # camera PUTs; requester observes their own ├── grants/ │ └── <deviceId>.json # persisted grant state (shared, auditable) ├── tokens/ │ ├── presented/<deviceId>.json # viewer PUTs on reconnect; camera observes │ ├── renewed/<deviceId>.json # camera PUTs; viewer observes │ └── revoked/<deviceId>.json # camera PUTs; viewer observes └── geo/ └── <instanceId>/ └── stream # exact geo data; live stream, ACL-gated ``` **Benefits:** - **Apoptosis is natural**: token expiry triggers DELETE of the path, which is itself an observable event - **Audit trail is intrinsic**: directory history IS the security log - **Grants become a shared canonical resource** (both camera and viewer see `/grants/<deviceId>.json`) rather than per-side localStorage that can drift - **ACL per path**: `/geo/<instanceId>/stream` is the resource where granted-ness is enforced, not a soft check in code - **WebRTC stays as a transport optimization** for the `stream` resource when peers are direct, but the URI is stable either way — a reverse-proxy agent at the URI picks WebRTC when available, WebDAV otherwise. Same identity, two transports. **Why it's secondary:** the camera flow is mid-flight with a working implementation and users' stored grants; migrating mid-product risks disrupting live demos. The ai-team migration touches the canonical pattern more cleanly with less exposure.

## Migration pattern (reusable across both) 1. **Define the new path shape** (one event per path; ACL per path or per subtree; cleanup by DELETE). 2. **Stand up a reverse-proxy agent** at the legacy URI. Reads legacy shape from new paths; accepts legacy-shape writes and splits into per-path writes. 3. **Cut new clients over to direct path I/O.** They stop reading the legacy URI. 4. **Retire the reverse proxy** once no legacy client remains. At any step between 2 and 4, both worlds coexist. Clients can migrate independently.

## Not-migration-candidates (by design) Some current group-based mechanisms should stay: - **Low-level peer signaling for WebRTC negotiation.** Paths are the wrong shape for sub-second ICE/SDP exchange. - **Group membership and capability declaration at join time.** That's identity, not events. Groups are the right primitive for "who is present in this namespace right now." - **In-process EventTarget / DOM CustomEvents inside a single page.** Paths are over-engineering for intra-tab communication. Paths-as-event-bus is for coordination events that are durable, ACL-governed, observable across peers, and benefit from audit. Everything else is still fine as in-process or direct-peer messaging.

## What this unlocks - **Mayordomos as path observers**: a mayordomo reading `/ecology/events/` (a binding composing many subtrees) is the canonical governance agent. - **Event persistence without a separate log.** Directory listing IS the log. - **Cross-origin event binding.** Harvard's questions can be mounted into redfish's namespace for shared review, without copying data. - **Chat recording becomes an ambient event stream**, not a side-channel file. Each message is a PUT to `<chat>/<msg-id>.md`; the transcript is the directory. - **22/22/56 observability**: agent topology becomes visible as actual resource counts (endpoints / branch-mayordomos / connector-routes), queryable by aggregation over paths.