docs(research): phase-0 research — the topic broker's placement, shape, and the op-as-topic tangle

OQ-42 (the broker) is deferred(scope) on two uncommitted blockers: a
consumer crate committing to fan-out (its named candidate, alktrader,
drifted away) and the composition layer's ownership (OQ-43). With four
published dependents and alkgit/alkfs growing subscription surfaces on
alkblobs downstream, deferral now compounds: a later broker delivery
ripples 6-8 crates vs riding the v0.9 bump. This research disentangles
the fused 'op as topic' concept (topic identity vs the
schema/ACL-validated op surface), inventories the delivered primitives
the broker composes on, proposes the in-crate opt-in composition-module
placement amendment (ADR-047 Gap B) and the dependency-free TopicStore
durability seam (in-memory default; mem/sqlite/postgres adapters
downstream, never an engine dep in alkcall), and enumerates eight
proposed open questions (QP-1..8) with convergence criteria for Phase 1.

No behavior change; docs/research is excluded from the published package.

Verified: cargo test (697 ok), clippy -D warnings, fmt --check, cargo doc --no-deps
This commit is contained in:
glm-5.3-flash committed 2026-10-13 19:47:42 +00:00
1 parent 3ca7505abd
commit 4278c13b2d
1 file changed
+400
+400
View File
@@ -0,0 +1,400 @@
# The Topic Broker: Placement, Shape, and the "Op as Topic" Tangle
**Status:** Phase 0 (exploration — captures the problem and the candidate
shapes; no decision taken, no ADR written. Implementation follows only
after this converges and Phase 1 produces the ADR + spec).
**Date:** 2026-10-13
**Research inputs:** operator recollection (2026-10-13, recorded and
disentangled in §3), the post-stub-sweep context (reviews 010/011, the
v0.9 release-plan discussion), alkcall ADRs 021/024/040/042/046/047/049/052/053
and OQ-22/39/40/42/43, the alkstore consumer inventory + ADR-015
(alkstore `/workspace/@alkdev/alkstore`), the TypeScript reference
implementation (`/workspace/@alkdev/pubsub`, `@alkdev/pubsub` — the
crates.io-unpublished ancestor of alkcall's `EventEnvelope`), and the
hub-relay composition (ADR-042, ADR-051).
---
## 1. Why this research exists
Two independent observations converged on the same question in the
v0.9 release-planning discussion:
1. **The sweep found its own neighbor.** The stub sweep removed the
last `not_implemented` surfaces and re-keyed the live docs — but the
OQ ledger still carries one deferral whose *premise expired and
whose escape route is itself unowned*. OQ-42 (the broker) is
`deferred(scope)`, parked on "a concrete producing crate committing
to fan-out," and its home is the composition layer (OQ-43) — whose
ownership and shape are *also* uncommitted. A deferral blocked on a
deferral, while two crates downstream (alkgit, alkfs) wait on what
it would produce, is the circular-hedging shape in paperwork form:
no false advertising on any API surface, no failing handler — just
a dependency chain where every link waits on a link that never
commits.
2. **The deferral was priced under a different dependency graph.**
ADR-046 §fan-out and ADR-047 Gap B ruled the broker out of alkcall
when the alternative was cheap: at that point, no downstream crate
built on the pub/sub vocabulary. Today, four published crates
consume alkcall, alkblobs' reactive ops surface is the sharpest
named need (the alkblobs consumer-remediation input, review 011),
and alkgit + alkfs (both paused on alkblobs) grow subscription
surfaces on top of it. If the broker is built later, the version
bump that delivers it ripples across every crate that grew a
subscription surface in the meantime — the operator's stated cost
estimate is 6–8 crates, vs riding the v0.9 bump that already touches
the 4 published dependents. Deferral here is not free; it
compounds.
So this document's job is narrow: disentangle the fused concept that
blocked honest specification (§3), inventory what already exists to
compose on (§5), assess whether the placement ruling should be amended
(§6), and enumerate the open questions that Phase 1 must resolve before
any code is written (§8).
## 2. Prior state of the question (what is decided today)
- **The primitives are delivered and load-bearing** (ADR-046): the
`Pub` operation type, `HandlerKind::Sink` + `SinkHandler`,
`invoke_sink()` dispatch, the `call.published` wire event (the
envelope's sixth event type, wire-stable), and
`CallConnection::publish()`.
- **The fan-out/broker was ruled out of scope for alkcall** (ADR-047
Gap B; ADR-046 §"Fan-out"): topic registry, `Pub`↔`Sub` matching by
`(op_name, params_hash)`, N-consumer fan-out — placed at the hub
composition layer, "the same way the hub relay composes on top of
call ops." OQ-22 resolved the primitive half; the broker is OQ-42.
- **The named first consumer drifted.** OQ-42's blocking condition
names "a concrete producing crate committing to fan-out (plausible
first candidate: alktrader market-data distribution)." alktrader has
since become a quant/backtest library: its market-data path is an
analytics input, not a live N-spoke distribution surface. No crate
currently matches the blocking condition — which is how OQ-42 ended
up uncommittable.
- **The "channels" consumer the original rationale named is served
differently.** ADR-046's deferral reason 2 was "the broker's first
consumer is channels" (`channel/resources/subscribe`, hub-as-broker
ADR-042). The discovery-UI half of `resources/subscribe` shipped as
the snapshot-then-changes enumerator surface (ADR-053) — live
resource enumeration, not a general topic broker. Hub relay
(ADR-042/051) composes call ops without a broker.
- **alkstore owns the durable substrate below all of this.** Its
consumer inventory (streams row) explicitly names alkstore streams as
the durable back of alkcall's pub/sub vocabulary — per-stream FIFO by
offset, per-consumer cursors, replay-on-attach — and pins the topic
dimension to *separate streams*, never per-key lanes within one
stream (alkstore ADR-015 §1).
## 3. The tangle: two ideas fused in "op as topic"
The operator's recollection (recorded verbatim, then split):
> "The 'op as a topic' was a bit hand wavy in hindsight. The general
> idea was to basically use a hash as a topic and for deduping
> purposes. The 'op as a topic' was mostly so one could call a
> subscription operation and receive a stream of events. The reason
> they were modeled like regular ops is that we can validate the input
> and provide a schema that is required for that particular
> subscription (same on the pub side). So it should allow for ACL
> patterns inside the pubsub implementation."
Two separable design needs got fused into one mechanism:
- **Need A — a topic identity for matching and dedup.** Something
hashable, stable, and comparable: the key used to route a published
event to matching subscribers, and (possibly) the dedup key that
prevents duplicate delivery. ADR-046 §fan-out's `(op_name,
params_hash)` is one instance of this.
- **Need B — a subscription surface with real operation properties.**
Subscriptions should be validatable (declare the input schema on the
subscribe side; declare the publish schema on the publish side), and
authorizable (`AccessControl::check` on both halves). The "model it
as a regular op" instinct exists so the *registry machinery* —
schema compile, ACL check, visibility, provenance — is inherited for
free rather than hand-rolled inside the broker.
Splitting them exposes a design space the fused formulation hid:
| Aspect | Need A (identity) | Need B (op surface) |
|---|---|---|
| Decides | which subscribers an event reaches; dedup | who may subscribe/publish; what shapes are accepted |
| Natural home | topic table / matching engine | registry registration (specs + ACL) |
| Failure mode if fused | topics inherit op-registry coupling they don't need; hash identity leaks op internals | ad-hoc topic surface re-implements schema + ACL machinery the registry already has |
The candidate resolutions for both are the Phase 1 subjects
(§8, QP-1/QP-3).
## 4. Consumer inventory (who waits on this)
Cross-referenced from the alkstore consumer inventory, review 011's
downstream input, and the release-plan discussion:
| Consumer | Need | Status |
|---|---|---|
| alkblobs | its reactive ops surface (Sink ops at their sharpest — REQ-2/ADR-008 lineage); ops-put works against the delivered `register_pub_openable` path post-fix (reviews 010/011), but cross-put fan-out ("many consumers of one blob stream") wants the broker | published-pending (unpublished crate, pre-first-release) |
| alkgit | repo-change subscriptions ("users and other apps subscribe to changes on a repo" — the operator's recorded need in alkstore's consumer inventory, streams row); paused on storage → alkblobs | paused |
| alkfs | the VFS workspace crate; its ancestor POC runs on honker notify; client-side cache invalidation (the "repo + cache invalidation" hot-path pattern the operator cites) wants topic-shaped invalidation signals | not yet decomposed |
| hub/relay deployments | one producer publishing, N spokes receiving (ADR-042's relay handles bytes-forwarding for channels; the *event-shaped* fan-out is the broker's) | composition layer, unowned (OQ-43) |
| alktrader | OQ-42's *original* named candidate | drifted — analytics, not distribution; do not re-park on it |
| family-wide | the reactivity requirement row (alkstore consumer inventory, streams): "watching for specific event types from several different places at once," durable, offline-tolerant | the named shape all of the above instantiate |
Note the family's shape consensus, recorded independently on both
sides: the topic dimension routes to **separate channels/streams — one
per topic** (alkstore ADR-015 §1; ADR-034 §substrate for wire
channels), never per-key lanes inside one stream. Whatever identity
model Phase 1 picks, it should preserve one-topic-one-delivery-pipe.
## 5. What exists to compose on (in-crate inventory)
Everything the broker needs as load-bearing inputs is already in this
crate and verified:
- **Delivery primitives** — `invoke_sink()` (`call.published`
sink-mode), `invoke_streaming()` (Sub, `call.responded`), the
wire-stable `EventEnvelope` framing with additive-event rule
(ADR-014: new event types may be added; existing shapes frozen).
- **Registry machinery for Need B** — `OperationSpec` (schema compile +
`publish_schema` validation), `AccessControl` + `required_scopes`,
`Visibility` (ADR-017), provenance (ADR-018), and the
registry-layering pattern (ADR-019) — session/connection overlays
for dynamic subscription attach/detach.
- **The in-crate composition precedents that weaken the placement
ruling** — `ChannelOperations` (channel lifecycle ops composed on
the call registry, ADR-047), establishers with `Establishment` plan
delivery (ADR-049/052), and `channel/resources/subscribe` — a live
subscription op with per-resource routing, snapshot-then-changes,
composed in-crate (ADR-053). None of these needed transport, ALPN,
or dial knowledge.
- **Peer vocabulary for cross-node fan-out** — ADR-024's peer-graph
routing model (`PeerRef` routing, peer-keyed overlays) is the
in-crate vocabulary for "deliver to N peers"; OQ-17 keeps
multi-hop out of scope.
- **Backpressure/lifecycle precedents** — bounded buffers, per-identity
caps, monotonic IDs (ADR-040/041), the abort cascade with
`continue-running` opt-in for slow-subscriber management (ADR-020).
- **The durable substrate downstream** — alkstore streams (per-consumer
offset, replay, trim), engines mem/sqlite/postgres under one
contract.
- **The TS reference** — `@alkdev/pubsub` (typed event-target: topic =
event type name; subscribe/publish/subscribeOnce vocabulary; the
`EventEnvelope` ancestor). Shape only; the Rust realization composes
on the registry instead of `CustomEvent`.
## 6. The placement ruling: amend ADR-047 Gap B?
The case for keeping the broker out of alkcall was: (1) point-to-point
call protocol, (2) the broker's lifecycle is routing-shaped, (3)
designing blind produces half-designs. Each has softened:
1. **Point-to-point is true of transport, not of composition.** The
registry-operations pattern (ADR-047) already puts composition ops
in-crate; ADR-053 ships a live subscriber op in-crate. A topic
registry is a *registry overlay + dispatch bookkeeping*, the same
class.
2. **The lifecycle concern dissolves at the module boundary.** A broker
module composed on a registry has exactly the lifecycle its
registry has; the "topic registry outlives individual calls"
objection describes a *deployment* property, satisfied by any
long-lived node, hub or not.
3. **The half-design risk is the real surviving constraint — and it
now has both halves.** The consumer side is no longer hypothetical
(alkblobs' ops surface, alkgit's subscription vocabulary recorded in
the alkstore inventory), and the substrate side exists (alkstore
streams, the TS reference). A Phase 1 spec against these is not a
blind design.
**Recommendation (non-final until §8 converges):** amend the placement
— the broker becomes an in-crate, opt-in composition module (the
`ChannelOperations` pattern): it registers topic ops into whatever
`OperationRegistry` the node owner points it at, carries no transport
or dial knowledge, and stays unregistered by default so no existing
surface silently gains it. What remains out of alkcall: engine wiring,
glue, dial — OQ-43's territory. The hub that fans out *across*
connections composes the module with its `CallConnection`s; within one
connection, the module composes locally. The durable event log behind
a topic's replay is a `TopicStore` trait seam (§7), not an in-crate
engine choice.
This also resolves OQ-42's uncommittable blocking condition: the
deferral's "wait for a consumer to commit" gate is replaced by shipped
consumers already on record.
## 7. The durability seam (the "not rocket surgery" constraint)
The operator's requirement: the trait seam must let a downstream
assembly add memory/sqlite/postgres adapters with the same ease as the
repo + cache-invalidation hot-path pattern — adapters are written once
per engine, not per deployment.
Shape to converge on in Phase 1:
- **In-memory is the default realization** — a `TopicBroker` with no
injected store behaves as the TS reference does: live fan-out,
stateless, zero durability.
- **The seam is one small trait** (working name `TopicStore`),
roughly covering: append a published event (returning the topic's
monotonically increasing position), read events at-or-after a
position for a topic, and per-subscriber cursor save/load. This maps
one-to-one onto the alkstore stream contract (`publish_with_key_tx`,
offset-ordered reads, per-consumer cursor saves — alkstore ADR-015
§2/§4), so the postgres/sqlite adapters are alkstore-streams-backed
and the memory adapter is the broker itself.
- **Reverse dependency discipline** — alkcall gains no alkstore
dependency. The seam stays dependency-free (plain trait, core-value
bytes crossing it, mirroring the vendored-core "lean, no engine"
posture); alkstore-backed adapters live downstream, where engines
already live (the OQ-43 glue consumes both crates).
- **Honest scope statement for durable mode:** durable delivery with
per-consumer replay is a *deployment* capability. The v1 broker
contract should specify the seam, not the durability guarantees
beyond it (ordering, dedup, retention pricing ride the store behind
the seam — for alkstore-backed adapters, those are ADR-015's pinned
rows verbatim).
## 8. Open questions (proposed — to be promoted into Phase 1's ledger)
These are the questions this research identifies as needing decisions
before any ADR or code. They're proposed in broker-local numbering
(QP-*) until Phase 1 promotes them as OQ-44+.
### QP-1: Topic identity — named topics, op-shaped topics, or a split?
Need A's model. Candidates:
- **(a) Named topics (strings).** topic = stream/event-type name; the
TS reference's shape; one-topic-one-pipe; alkstore-stream-aligned.
Matching is exact-name; wildcarding is optional and additive.
- **(b) Op-shaped (`(op_name, params_hash)`), ADR-046 §fan-out's
literal form.** Fan-out targets are Sub-typed ops; identity inherits
op internals. Pros: dedup key falls out; wire-consistency with
existing ops. Cons: the identity couples routing to op schema shape
(params_hash stability depends on the schema's canonicalization —
which is a real design burden, see §3's A/B split); one topic per op
per registrant, so cross-op co-subscription needs the hash of the
*matching rule* anyway.
- **(c) The split:** identity is named topics (a); "ops as topics"
survives only as Need B — a subscriber declares a topic *and* the
input schema it validates against, so per-subscription input
validation is a subscriber-side schema, validated at subscribe time
and at delivery time.
Working recommendation: **(c)** — identity as named topics, schema/ACL
inheritance via the registry on the two broker ops, dedup keying
re-derived from topic semantics rather than op params.
### QP-2: Dedup and delivery semantics
"A hash as a topic ... for deduping purposes" — what does dedup mean
here, precisely? Candidate framings to resolve: idempotent-publish
keys (producer-supplied event ids, dedup at the store seam), vs
subscriber-side dedup (at-least-once delivery + a dedup window), vs
no dedup contract in v1. The answer is pinned partly by the store
seam: durable adapters can offer producer-supplied-id dedup for free;
in-memory mode cannot (and shouldn't claim to). The contract must
state delivery semantics honestly per mode (live: at-most-once per
attached subscriber; durable-with-replay: at-least-once per cursor)
rather than promising a uniform thing two substrates can't both
provide.
### QP-3: Wire shape — new ops, new event type, or composed handler kinds?
- Broker ops registered into a registry (`topic/subscribe`,
`topic/publish`, optionally `topic/topics` listing) — additive, no
envelope change required: subscribe rides `call.responded` (Sub),
publish rides `call.published` (Sink).
- If wire-visible events beyond op names are wanted (e.g. per-topic
attach/detach events), they are *new envelope event types* — legal
under ADR-014's additive rule, but each one is a cross-language
deliverable to keep in sync; default to zero new event types.
### QP-4: ACL and schema on both halves
The operator's requirement, mapped to the registry: the broker ops
carry their own `AccessControl` (who may subscribe/publish at all) —
and each *topic's* authorization model (per-topic publish ACL, per-
topic subscribe ACL, maybe scope patterns) is the piece the fused
concept gestured at with "ACL patterns inside the pubsub." Decision
needed: per-topic ACL tables owned by the broker module vs topic ops
as individually registered specs (each topic's publish/subscribe ops
registered separately, inheriting the full registry ACL/visibility/
provenance machinery). The latter is the pure Need-B realization but
costs a registration per topic; the former is one registration pair
with a topic-table lookup. This materially changes the decomposition.
### QP-5: Subscription lifecycle
Attach (subscribe with optional position for durable mode), detach,
disconnect behavior (live mode: gone on transport drop; durable mode:
cursor persists, resubscribe at position — the alkstore streams row's
contract), subscriber cap policy (ADR-041 pattern), and interaction
with the abort cascade (ADR-020) when a subscriber's sink stream dies.
### QP-6: Cross-node fan-out and the hub's margin of work
Within one node: registry dispatch. Across nodes: the hub composes (it
holds `CallConnection`s; ADR-024 vocabulary). Decision: does v1 ship
any in-crate cross-connection fan-out helper (a `FanOut` that mirrors
`call.published` streams into peer connections), or does the hub wire
that itself from the primitives? ADR-042's relay exists for channels
byte-forwarding; its analog for event fan-out is un-specced — it is
either a small in-crate helper or hub-side composition, and the
placement ruling (§6) should state which explicitly so OQ-43 doesn't
inherit an ambiguity.
### QP-7: Backpressure across fan-out
One slow subscriber must not back-pressure the topic (the OQ-23/ADR-040
lesson carried into event delivery): per-subscriber bounded queues with
an explicit slow-subscriber policy (drop + event, drop + disconnect,
block-bounded). Policy default and configurability are to be pinned in
Phase 1; the ADR-040 bounded-buffer machinery is the reference.
### QP-8: Ordering guarantee
Per-topic FIFO (matches alkstore ADR-015 §1's pinned row and ADR-046's
per-stream delivery posture). The open part: does the broker promise
ordering *across* publishers to one topic (single sequencer required)
or only per-publisher (interleaving legal, no sequencer needed)? The
durable store behind the seam forces single-writer-per-topic-sequencer
for (a); in-memory could do either — but the contract has to match the
store's row, so this resolves with QP-2 rather than independently.
## 9. Recommendation and convergence criteria
**Direction (to carry into Phase 1, not final):** an in-crate,
opt-in broker composition module on the call registry — named topics
for identity (QP-1c), registry machinery providing schema + ACL on the
two broker ops (QP-3, QP-4 resolved as one topic-table vs per-topic
registration decision), a dependency-free `TopicStore` seam with the
broker itself as the in-memory adapter (QP-D from §7), per-topic FIFO
with an honest per-mode delivery contract (QP-2, QP-8), ADR-040-style
per-subscriber backpressure policy (QP-7), and an explicit statement
on the cross-node margin (QP-6). ADR-047 Gap B is amended by the Phase
1 ADR (which supersedes the placement ruling for this scope); OQ-42 is
resolved by that ADR; OQ-43 remains untouched (dial/engine/glue is
still the composition layer's).
**Convergence criteria — Phase 0 is done when:**
1. Every QP-* above carries a candidate resolution with its rejection
cases written down (the ADR-032 one-way-door discipline applies to
the wire-visible ones).
2. At least one concrete consumer surface has signed off on the topic
model — alkblobs' put-path ops for the publish half, the recorded
alkgit subscription vocabulary for the subscribe half (the review
011 downstream-input pattern: the consuming crate drives the
contract).
3. The QP-4 decision (topic-table vs per-topic registration) is made —
it is the largest decomposition determinant.
4. The placement amendment's blast radius is enumerated: which ADRs/
docs re-key (046 §fan-out, 047 Gap B, open-questions OQ-42, README
ADR tables; alkstore consumer-inventory rows are cross-repo notes,
updatable but not blocking).
**Out of scope for this research:** implementation, task decomposition,
version assignment, wire-format drafting. Nothing here creates a
`not_implemented` surface — the broker module does not exist and no
API names, docs, or changelog entries will advertise it until Phase 1's
ADR lands, per the implement-or-stop-advertising ruling (the only
permitted deferral statement is "not this session").