agent: contact request rejection and service requests#1833
Conversation
There was a problem hiding this comment.
Adds two agent capabilities on top of the DR-enabled contact address feature: (1) contact-request rejection with an optional reason delivered over the double ratchet (RJCT), and (2) a service RPC round-trip — sendServiceRequest blocks for a single AgentServiceResponse/AgentRejection reply, with sync and async variants on both sides.
I read every touched file in full, followed the message flow through Agent.hs (serviceRequest_, prepareReply, sendReply{Sync,Async}, joinConnSrv', smpConfirmation, smpContactRequest, ICReplyDel, cleanupManager), the store/schema/migration changes, and the protocol encodings. The design is coherent and the tests exercise sync, async, rejection, and cross-outage resilience. Encodings don't collide (AgentMessage A/P/J, AgentMessageType A/P/J vs existing tags), smpConfirmation is always followed by ack, the in-memory serviceRequests TMVar map is keyed by a freshly-generated connId so concurrent requests can't collide, and the orElse (registerDelay) timeout logic is correct. Leftover requester connections and unanswered received requests are cleaned up by getExpiredServiceConns/deleteExpiredServiceRequests. No blocking issues found.
Non-blocking observations for the author:
-
Request vs response timeout asymmetry. The requester's end-to-end wait is
serviceRequestTimeout(30s) while the responder may still reply withinserviceResponseTimeout(180s) —prepareReplyaccepts requests up to 180s old andAM_SRV_RESPdelivery is retried for 180s. A reply produced between 30s and 180s arrives after the requester has already timed out and torn down the reply connection, yieldingERR (A_SERVICE ASENoPendingRequest)and a wasted round-trip. If the 30s/180s split is intentional (interactive requester patience vs. service-side lifetime), fine; otherwise the requester wait should be>=the response window. Confirm this is deliberate. -
Non-DR contact address silently degrades to a contact invitation.
sendServiceRequest'→joinConnSrv'CRContactUri: when the target address publishes no ratchet keys (ratchet_ == Nothing), themkInner/AgentServiceRequestpayload is ignored and a plainAgentInvitationis sent. The recipient then sees a normalREQ(notSREQ) and the requester blocks until the 30s timeout. Service RPC only makes sense against a DR address, so consider failing fast with a clear error in that branch rather than emitting a contact invitation and timing out. -
Minor duplication. The
ICReplyDelhandler body (secure snd queue →submitPendingMsg→deleteConnectionAsync' True) is identical tosendReplySyncwithnm = NRMBackground; could callsendReplySyncdirectly. -
Migration rename.
M20260712_address_dr→M20260712_address_dr_rpc(name and column additions) is safe only because theaddress_drmigration is unreleased; renaming an applied migration would break the migrations table. Assuming this branch supersedes the still-unreleased address-dr work, this is fine.
c59629a to
98caf57
Compare
There was a problem hiding this comment.
Reviewed the full branch (d5f48de3b56b...ccc1d7f8eca5): all 18 files, following the new agent APIs, the store/migration changes, and the SMP transmission handling.
The change adds two capabilities over contact-address double-ratchet connections:
- Contact request rejection with a reason (
rejectContact/rejectContactAsync), surfaced to the requester as the newRJCTevent. - Service (RPC) requests (
sendServiceRequest/Async), with responses (sendServiceReply/Async) and rejections (rejectServiceRequest/Async), surfaced to the service as the newSREQevent.
The wire changes are consistent: AgentInvitationDR→AgentContactRequest (envelope tag 'J'→'A'), three new AgentMessage variants (A/P/J) with matching AgentMessageTypes, and the new AEvent/AEventTag constructors are threaded through aEventTag. Roundtrip encoding is covered by the new ConnectionRequestTests case, and the async/resilient flows have functional tests. The serviceRequests TMVar map, the timeout handling (serviceRequestTimeout/serviceResponseTimeout), the ICReplyDel deferred-reply command, and the cleanup path (getExpiredServiceConns/deleteExpiredServiceRequests) all hang together correctly, including the orphaned-connection GC after an agent restart.
Concurrency and lifecycle look sound: the TMVar is inserted before the send is enqueued, the blocking caller uses registerDelay+orElse for timeout, and stale/cancelled requests are handled by the Nothing branch in the async JOIN JRServiceReq worker. The serviceResponse connection flag and service_request invitation flag are persisted with sensible migration defaults (created_at defaulting to epoch for pre-existing rows, which never match the service_* GC predicates).
Only one nit found — a typo in a new record field name (conntactReq), suggested inline.
Two non-blocking observations:
- Migration rename.
M20260712_address_dris renamed toM20260712_address_dr_rpc(folding in the new columns) rather than adding a new migration. This is correct only if20260712_address_drhas never shipped to any client DB; if it has, deployed installs recorded the old name and would re-run theCREATE TABLE address_ratchet_keys. Given both migrations share the same date and author and this is a stacked branch, it appears to be in-development — worth a quick confirm. - Payload size. Service request/response payloads are encrypted into the fixed
e2eEncConnInfoLengthenvelope (~11KB with PQ, ~14.8KB otherwise). Payloads beyond that fail encryption and surface as an error to the caller. Fine as a design bound for RPC, just noting it.
5cf2757 to
b0049a7
Compare
There was a problem hiding this comment.
Adds two related capabilities on top of the address-DR feature: (1) contact-request rejection with a reason delivered over the double ratchet, and (2) service (RPC) requests — a client sends a signed request to a DR-enabled contact address and blocks for a single response/rejection. New agent APIs (sendServiceRequest[Async], sendServiceReply[Async], rejectServiceRequest[Async], rejectContact[Async]), new wire messages (AgentServiceRequest/Response/Rejection, RequestSignature), new events (SREQ/SSENT/RJCT, and a Bool added to REQ), a serviceRequests in-memory correlation map, DB columns for request expiry/kind, and an ICReplyDel internal command for durable async replies.
I read the full cumulative diff and traced the touched code paths (Agent.hs RPC/reply/delivery, Protocol encodings, Store/AgentStore, migrations, Client/Env, Crypto, tests). The implementation is coherent and well-tested (sync + async + resilience across server outages). No blocking issues found. Notes below.
Verified correct
- Signature binding.
serviceReqBinding=sha3_256("SimpleXService" <> rcAD); a subagent confirmedrcADis byte-identical on requester (Snd ratchet) and service (Rcv ratchet), so signatures verify. The signature also travels inside the DR-encrypted envelope, and an unsigned request yieldskey_ = Nothingfor the service to reject at the app layer. - Timeout/dispatch STM in
serviceRequest_(takeTMVar var \orElse` registerDelay) is correct; late replies after cleanup surface asASENoPendingRequest` rather than corrupting state. deleteConnectionAsync' c True …useswaitDelivery=True, so the stored reply (AM_SRV_RESP/AM_RJCT, sent viasendConfirmation) is delivered before the reply connection is torn down.- Async JOIN handler serialises with
serviceRequest_cleanup viawithConnLock; a request whose waiter already vanished (Nothing inserviceRequests) is dropped rather than sent, and the persisted command is removed. Replay of a contact/service request is dropped via the existing ratchet-key-hash de-dup. - Envelope tag
'J' → 'A'(renameAgentInvitationDR → AgentContactRequest) and the new innerAgentMessagetags don't collide;JoinRequest's'S'prefix disambiguates fromJRConnReq/JRInvitationDR.
Worth confirming (non-blocking)
- Timeout asymmetry. Default
serviceRequestTimeout(client wait) is 30s butserviceResponseTimeout(service validity/expiry window) is 180s. A reply produced between 30–180s arrives after the client has already returnedASETimeoutand is discarded (ASENoPendingRequest). This is fine given per-request override, but confirm the defaults are intended. Crypto.hsenablesStrEncoding (PrivateKey Ed25519)globally (removing the "Do not enable, to avoid leaking key data" guard) so the signing key can be persisted inside theJRServiceReqasync command. This mirrors the already-enabled X25519 instance and the key lives only in the (encrypted) agent DB, but it does broaden the surface for accidental serialization of a private key. Consider whether a scoped/newtype instance would be tighter.- Migration
M20260712_address_dris replaced in place byM20260712_address_dr_rpc(same date, new id) rather than added as a follow-up migration. Safe only if20260712_address_drwas never applied anywhere (unreleased); on any DB that already ran it, the new migration'sCREATE TABLE address_ratchet_keyswill fail. Confirm no such DBs exist. getExpiredServiceConnsscansconnectionswith no index onservice_request_expires_ateach cleanup cycle. Consistent with existinggetDeletedConnIds, so not new, but keep in mind for large stores.- Minor: the
kindErrstrings inprepareReply/rejectRequest_are duplicated with slightly divergent wording, andsendReplySyncrepeatsdeleteConnectionAsync' c True connIdin both branches — readable as-is, low priority.
One tiny inline suggestion (stray blank line in AgentConfig).
There was a problem hiding this comment.
Adds contact-request rejection (with a DR-carried reason) and a request/response service-RPC layer over contact addresses. The design is sound and reuses existing structure well: the reply travels back as an SMP confirmation on the requester's reply queue, the signature is bound to the double-ratchet associated data so it cannot be replayed to another service or request, and the request/reply state machine keys off serviceRequestExpiresAt (client side) and the service_request flag on the invitation (service side). I verified the two points that could silently break this:
rcAD(the signature binding source) is provably identical on both peers — it ispubKeyBytes(joiner_pub1) <> pubKeyBytes(creator_pub1)on bothpqX3dhSndandpqX3dhRcv, soverifyServiceReqwill agree withsignServiceReq.- The service reply is acked (
smpConfirmation ... >> ack), sodispatchServiceReplywon't fire a spuriousASENoPendingRequeston redelivery.
Concurrency around serviceRequests is correctly serialized: both serviceRequest_ cleanup and the async JOIN handler take withConnLock c connId, and the timed-out waiter deletes the map entry before the retrying command re-checks it (finding Nothing and skipping the send). Encodings are collision-free (AgentMessage/AgentMessageType tags A/P/J don't clash) and round-trip tested. The migration rename M20260712_address_dr → _rpc is fine since the original is unreleased.
Two non-blocking observations (no code change requested):
connections.created_atis added to the migration and schema and written increateConnRecord, but never SELECTed anywhere in simplexmq. If it isn't needed by a consumer, consider dropping it — it's dead weight on aWITHOUT ROWID, STRICTtable, and the migration is unreleased so removal is cheap. Cleanup and expiry useconn_invitations.created_atandconnections.service_request_expires_at, not this column.- Delivery retry timeout for
AM_RJCTfalls through tomessageTimeout(2 days), whileAM_SRV_RESPusesserviceResponseTimeout(180s). A service rejection is only awaited by the requester forserviceRequestTimeout(30s), and the service-side reply connection carries no service marker, so it isn't reclaimed bygetExpiredServiceConns; an offline requester leaves the service retrying a rejection for up to 2 days.AM_RJCTis shared with contact rejection (where 2 days is intended), so there's no clean per-msgTypefix — worth a look if service-rejection connection buildup matters.
One trivial inline fix below (the previously-flagged blank line in AgentConfig, still present).
Co-authored-by: simplex-chat-agent[bot] <287173099+simplex-chat-agent[bot]@users.noreply.github.com>
No description provided.