Solution 035: Agora — Topic-Addressed Record Relay — Implementation Guidelines¶
Proposal: doc/project/40-proposals/035-agora-topic-addressed-record-relay.md
Solution: doc/project/60-solutions/008-agora/008-agora.md
Capability catalog: doc/project/60-solutions/008-agora/008-agora-caps.edn
Purpose of this document¶
This note is the implementation entry point for the Agora relay. It does
not duplicate the per-capability status (that lives in
agora-caps.edn and is surfaced by CAPABILITY-MATRIX.md) and it does
not try to replace the fine-grained backlog (that lives in
doc/project/60-solutions/008-agora/008-agora-backlog.md).
It exists to:
- name the layers and the crates they map to,
- fix the invariants that hold across layers (signing domain, canonical
bytes,
record/idformula, topic ACL evaluation point), - give readers a stable commit order when reshaping the stack,
- point at the P-item backlog for per-task status.
Crate stratification¶
The runtime side of Agora is split into a thin stack of crates, each owning one concern:
| Layer | Crate | Role |
|---|---|---|
| L0 | agora-core |
envelope types, canonicalization, agora.record.v1 domain, sign adapter, delegation verifier trait |
| L1 | agora-relay-trait |
AgoraRelay, SubjectIndex, IngestReceipt, topic ACL contract, subscribe opts |
| L2a | agora-relay-sqlite |
SQLite-backed local relay (records, topics, subject index) |
| L2b | agora-relay-mem |
in-memory relay for tests and loopback experiments |
| L3 | agora-matrix-client |
Matrix HTTP sink (send event, sync) |
| L3.5 | agora-relay-matrix |
federated MatrixBackedRelay combining a local relay with the Matrix sink/transport |
| L4 | agora-http |
AgoraHttpApi — request handlers over the relay trait, error mapping, SSE stream |
| L4.b | agora-capability-bridge |
P12 DelegationProofVerifier impl that reads capability crate types |
| L5 | agora-service |
supervised middleware binary: HTTP server + relay stack + retention sweep |
| L6 | daemon |
bundled middleware wiring (executable override, config materialization), capability lookup surface |
Each layer compiles and makes sense independently. Swapping a backend means swapping L2a/L2b; swapping transport means swapping L3/L3.5; swapping the delegation verifier means swapping L4.b.
Invariants that cross layers¶
These must be identical wherever they appear. If they drift between layers, archival and federated verification will silently diverge.
- Signing domain. The constant
AGORA_RECORD_DOMAIN_V1 = "agora.record.v1"is the only domain tag used for Agora record signatures. It is prefixed before the canonical bytes at sign time and at verify time. - Canonical signed bytes.
canonical_signed_bytesis the JCS canonicalization of the envelope with these fields pruned:signature,relay/received-at,relay/id. Therecord/idis included (post-compute) so that it participates in the signature. record/idformula.sha256:<base64url-no-pad(sha256(canonical_content_address_bytes))>. The prefix is literal; the hash is SHA-256; the encoding is the same URL-safe no-padding convention used by the node's other signed artifacts.- Participant id shape.
participant:did:key:<base58btc ed25519>. ThePARTICIPANT_ID_PREFIXconstant lives in signer-core and is reused byagora-corewhen binding the returned public key to the author. - Topic ACL evaluation point. ACL is evaluated inside the relay trait impl on ingest, before persistence, and again on federated inbound after envelope re-validation. Never at the HTTP layer only.
Layer 0 — JSON Schemas¶
Canonical schemas:
agora-record.v1— envelope.resource-opinion.v1— content schema forrecord/kind = "opinion"with a URL subject (proposal 026).plain-comment.v1— content schema forrecord/kind = "comment".key-delegation.v1— already published; referenced inline by P12 delegated signing (optional).
Schema publication happens through the daemon's schema surface (same style
as /v1/schemas/key-delegation). Keep schema names stable; do not encode
relay role or topic into the schema id.
Layer 1 — agora-core¶
Envelope and canonical bytes¶
AgoraRecordstruct with slash-style keys preserved verbatim on the wire (record/id,record/kind,topic/key,author/participant-id,authored/at,content/schema,content,record/about,signature).canonical_signed_bytes(&AgoraRecord) -> Vec<u8>— JCS with the pruning rules above.compute_record_id(&canonical_content_address_bytes) -> String—sha256:<base64url-no-pad(sha256(...))>.
Sign adapter (L2 into host signer)¶
-
sign_agora_record_via_host(record, key_ref, host_signer) -> Result<..., SignAdapterError>: -
compute canonical bytes of the unsigned envelope,
- request a signature in the
agora.record.v1domain, - verify the signer's returned
key_publicagainst the author's participant id (hard block,SignAdapterError::PublicKeyMismatch), - populate
record/idandsignature.value, return the record. - Never skip step 3 even when the key_ref is
PrimaryParticipant. The check is what prevents a misconfigured signer from producing a record whose author id disagrees with its signature.
Delegation verifier trait (P12 seam)¶
trait DelegationProofVerifier { fn verify(&self, record: &AgoraRecord, proof: &serde_json::Value) -> Result<(), DelegationVerifyError>; }RejectingDelegationVerifieris the fail-closed default. A production deployment installsCapabilityDelegationVerifierfromagora-capability-bridge.- The docstring in
agora-core/src/signature.rsexplicitly commits to this seam so thatagora-corestays free of anycapabilitycrate dependency. Do not regress this by importing capability types into agora-core.
Layer 2 — relay trait and storage backends¶
AgoraRelay trait¶
Minimum methods:
ingest_signed(record: AgoraRecord) -> Result<IngestReceipt, IngestError>get_record(id: &str) -> Result<Option<AgoraRecord>, RelayError>list_topic(key: &str, opts: PageOpts) -> Result<RecordsPage, RelayError>list_subject(kind: &str, id: &str, opts: PageOpts) -> Result<RecordsPage, RelayError>subscribe_topic(key: &str, opts: SubscribeOpts) -> Result<TopicStream, RelayError>
SQLite backend (agora-relay-sqlite)¶
- one file, multiple tables:
records,topics,subject_index,sweep_cursor. - idempotent ingest by
record/idunique constraint (duplicate → 200 OK in the HTTP layer). - subject index is maintained synchronously on ingest;
rebuild_subject_indexon startup covers crash recovery.
Memory backend (agora-relay-mem)¶
Same trait surface, in-memory BTreeMaps, used for tests and for loopback
experiments that do not need durability.
Layer 3 — Matrix transport¶
agora-matrix-client¶
HttpMatrixEventSink—/_matrix/client/v3/rooms/{room}/send/{type}/{txn}, plus sync.- fails closed on non-2xx; retries are the caller's concern.
agora-relay-matrix¶
MatrixBackedRelaycomposes a local relay +MatrixRelayTransport+SubjectIndex.- three roles (
canonical,cache,origin) gate outbound/inbound and ACL authority per topic. - inbound Matrix events are transport carriers only: the bridge must verify the
embedded
agora-record.v1envelope, signature/delegation proof, content-schema, topic ACL, authority/capability/revocation policy, and idempotency before local persistence. - donor relay identity and Matrix event signatures are diagnostics/provenance; they do not replace local admission.
- bridges are started once at service startup
(
start_configured_bridges()), never inside request handlers.
Layer 4 — HTTP surface¶
agora-http¶
AgoraHttpApiholdsArc<dyn AgoraRelay>andArc<dyn SubjectIndex>.- Endpoints (same shape at v1 and v2 if that ever comes):
| Method | Path | Handler |
|---|---|---|
| GET | /v1/agora/status |
status snapshot |
| GET | /v1/agora/records/{id} |
record-by-id |
| GET | /v1/agora/topics/{key…}/records |
topic page |
| POST | /v1/agora/topics/{key…}/records |
post signed record |
| GET | /v1/agora/topics/{key…}/subscribe |
SSE stream |
| GET | /v1/agora/about/{kind}/{id}/records |
subject-index page |
| POST | /v1/agora/records.sign |
sign unsigned envelope through host signer |
-
POST /v1/agora/records.signis the only HTTP surface whererecord/id = "sha256:pending"is meaningful. It is a signing-input placeholder, not a valid final record id. The handler computes the real content address, writes the signature, self-verifies, and returns a final envelope. Normal ingest/replay/federation rejectsha256:pendingas a content-address mismatch. -
Error mapping:
PublicKeyMismatch→ HTTP 422author_signature_binding_failed,TopicAclDenied→ HTTP 403topic_acl_denied,DelegationVerifyError::*→ HTTP 422 with per-variant error code (one code per verify-error variant — schema mismatch, proxy/principal key mismatch, principal signature invalid, expired, grants insufficient),-
duplicate
record/id→ HTTP 200 withduplicate: true. -
SSE frame:
event: agora.record\ndata: <json>\n\n. One event per accepted record; the stream never reorders events across the authored-at axis on a single topic.
Layer 4.b — agora-capability-bridge (P12)¶
Separate crate so that production nodes install the capability-backed
verifier while tests can run with RejectingDelegationVerifier. This
layer is implemented in node as orbiplex-node-agora-capability-bridge.
Contract summary:
CapabilityDelegationVerifierparses theserde_json::Valueproof into the typedcapability::DelegationProof,- validates schema, proxy key match to
signature.key/public, principal match toauthor/participant-id, grants includesigning/agora-recordfortopic:<topic-key>(or wildcard), and expiry againstrecord.authored/at(deterministic, not wall clock).
Full phase plan for this layer lives in the sibling node repository
alongside the other Agora implementation notes; this solution document
only fixes the contract.
Layer 5 — agora-service (supervised middleware binary)¶
Shape¶
- binary crate under
node/agora-service/, entrysrc/main.rs, - loads JSON config from
$ORBIPLEX_NODE_CONFIG_DIRsection"agora_service", - supports both the middleware supervisor protocol (
/readyz,/healthz,/v1/middleware/init,/shutdownz) and the Agora HTTP API under/v1/agora/...on the same listen port, - spawns a retention sweep thread using the configured interval.
Middleware init report¶
Returns MiddlewareModuleReport advertising exactly one capability:
agora.relay (class Other). No input_chains, no local_routes — this
middleware does not participate in the dispatch chain; clients connect to
the port directly once the daemon's capability lookup returns it.
Auth¶
/v1/middleware/initand/shutdownzvalidateAuthorizationagainst the token loaded from$ORBIPLEX_MIDDLEWARE_AUTHTOK_FILEusing the header name in$ORBIPLEX_MIDDLEWARE_AUTH_HEADER(defaultAuthorization)./readyz,/healthzare open (same as other supervised middleware).- Agora endpoints are auth-free at this layer; authorization is the topic ACL's job, not a per-request token.
Path handling¶
- percent-decoding on every path segment that may include slashes (topic
key, record id, subject id). Do not decode
+ → space— that is HTTP query semantics only. - query-string decoding supports
+ → space.
Error diagnostics¶
Replace .expect() in build_relay with unwrap_or_else(|e|
panic!(...)) that includes the SQLite path, the homeserver URL, the
relay_id, and the role. These messages are read at 3am by operators who
did not write the code.
Layer 6 — daemon wiring¶
Config-driven activation¶
Agora is not enabled by default. The operator enables it by adding an
"agora_service" section to the Node config (typical path:
<data_dir>/config/30-agora.json). The bundled fabric config
middleware-modules/agora-service/config/00-agora-service.json has
seed_config: false so the daemon does not auto-materialize it.
The daemon's build_generated_http_local_executor_config() is extended
with a generic executable override:
- if the bundled config carries
"executable": "run.sh", resolve it relative to the middleware module directory and use it instead of the default Python runner; - otherwise fall back to
bundled_middleware_runner_script()(Python middleware shape).
This change is generic — it applies to any bundled middleware that ships a native binary, not just Agora.
Launcher¶
middleware-modules/agora-service/run.sh locates the compiled
orbiplex-node-agora-service binary in
$WORKSPACE/target/release|debug, falls back to $PATH, and execs it
with the listen port as the first argument.
Capability lookup¶
The daemon's host capability API exposes
GET /v1/host/capabilities/agora.relay
X-Orbiplex-Authtok: <daemon control token>
which returns the local provider (module id, endpoint URL, transport, scope,
description, passport). For Node UI this is a daemon control-plane read using
the control token; middleware modules use their separate
X-Orbiplex-Module-Authtok token for daemon-owned POST capabilities such as
signer.sign.
If the capability is known but no provider is ready, the daemon returns 200
with an empty providers array. If the capability is unknown, it returns
404 capability_unknown. The UI maps both cases to an explicit no-local-relay
or capability-unavailable state rather than silently falling back to a public
relay (story-008 acceptance criterion 2).
Agora CLI TLS trust¶
The Rust orbiplex-node-agora CLI supports both plain local http://
and TLS-protected https:// Agora base URLs. HTTPS uses the bundled
WebPKI root store by default. Operators may add private trust anchors
with --tls-ca-file PATH or ORBIPLEX_AGORA_TLS_CA_FILE; the CLI flag
has priority over the environment. Empty or malformed CA files fail
closed.
--tls-insecure-skip-verify exists only as a diagnostic escape hatch.
It disables certificate verification and prints a warning to stderr
stating that the mode must not be used in production. This keeps local
debugging possible without making insecure TLS a quiet or normal
deployment mode.
Layer 7 — Node UI discovery¶
The Node UI does not ship a hardcoded Agora endpoint. For compose flows
(e.g. the "opinion about URL" form from story-008) it calls the host
capability API above and uses the first scope: "local" provider. If none
is returned, the UI shows a clear "no local relay available" notice and
does not offer a remote fallback.
Recommended commit order when reshaping the stack¶
- Schemas first. Any envelope or content shape change lands as a
schema update, with the old schema either deprecated or versioned
(
agora-record.v2) before any code reads the new shape. - L0 (
agora-core). Canonicalization, domain constant, sign adapter, delegation trait. - L1 (
agora-relay-trait). Trait changes must preserve existing impls; add methods with default impls where possible. - L2 (backends). SQLite first, memory second — the test backend should never outrun the production one.
- L3 (Matrix). Only once backends compile; federation is an amplifier of existing bugs.
- L4 (
agora-http). HTTP surface changes after the core is stable. - L4.b (
agora-capability-bridge). P12 bridge verifier — implemented after the record-level fields and verifier trait landed in L0. - L5 (
agora-service). Binary integrates the above. - L6 (daemon wiring). The
executableoverride + bundled config. - L7 (Node UI). Discovery UX.
Each step should leave the tree in a compiling state.
MVP boundaries¶
Keep these restrictions explicit in both code and docs:
- one author per record; no co-signatures, no multisig,
- no sub-topic ACL inheritance — topic keys are flat strings treated as a namespace, not a tree with permission descent,
- no wildcard revocation of past records (records are immutable once ingested; retention sweep is the only removal path),
- no automatic cross-relay federation before an explicit
agora.relaypassport is issued, - no content-semantic interpretation inside the relay — Agora does not
read
contentbeyondcontent/schemafor ACL and retention purposes, KeyRef::Proxyis runtime-rejected (HTTP 422delegated_signing_unsupported) until L4.b lands.
Testing posture¶
- Golden vectors for
canonical_signed_bytesandrecord/idinagora-core/tests/. One vector per content schema. These vectors are the federation-level contract — they do not change without a schema version bump. - Roundtrip tests for each backend: ingest → list → get-by-id → subject-list.
- Federation tests in
agora-relay-matrix/tests/using the Matrix mock sink fromagora-matrix-client. - SSE harness in
agora-http/tests/— subscribe, ingest, assert delivery order. - Service integration test in
agora-service/tests/— spawn the binary with a temp SQLite, POST a signed record, GET it back, close.
Related documents¶
doc/project/60-solutions/008-agora/008-agora.md— solution-level description.doc/project/60-solutions/008-agora/008-agora-caps.edn— capability catalog.doc/project/30-stories/story-008-cool-site-comment.md— first end-to-end user story.doc/project/40-proposals/035-agora-topic-addressed-record-relay.md— source proposal.doc/project/40-proposals/026-resource-opinions-and-discussion-surfaces.md— content schema for URL opinions.doc/project/60-solutions/008-agora/008-agora-backlog.md— granular P1–P13 backlog (source of truth for status of implementation tasks).doc/project/60-solutions/008-agora/agora-record-relay.v1.openapi.yaml— public Agora HTTP OpenAPI contract.node/docs/implementation-ledger.toml— per-crate implementation status.doc/project/40-proposals/032-key-delegation-passports.md— source proposal for the P12 delegated-signing layer.