Beacon Ack — Group 22, Code 3 (proposed)

A device tells its beacon RAIDA that it has durably stored one or more Tells. The server records the acknowledgement for that (mailbox, device) pair only. Nothing is deleted and retention is not shortened; the only effect is that a Wait (group 22, command 2) from the same device with hide_acked set no longer returns those Tells. Other devices on the same mailbox are unaffected.

Status of this proposal

Not implemented

Group 22 is not in raidax or rest_core. This command is the “negotiated per-device durable ACK” that the retained-notification amendment lists as follow-on work. This page is the design to review. Legacy Ping and Peek stay non-destructive whether or not Ack is deployed.

Why it is needed

Since Tells are retained for 30 days and fetching no longer deletes them, a client sees the same notifications again on every poll that starts at or before their timestamp. Today the client hides that by deduplicating against its local database, which works but has costs:

  1. Every poll on a busy mailbox carries the backlog. A fresh cursor, a cursor held back for skew, or any batch that was cut returns Tells the device already has. On a mailbox with hundreds of retained Tells that is hundreds of records per poll, against a 1 MiB cap.
  2. The server cannot tell delivered from undelivered. There is no signal from any device that a Tell reached it, so the server can neither report delivery to an operator nor prioritise Tells that no device has fetched.
  3. Multi-device mailboxes have no per-device state. A phone and a desktop polling the same mailbox each need their own view of “what is new to me”. Only the server can hold that view; a per-device cursor alone cannot express “I have this one but not that older one”.

Ack gives the server exactly that signal, scoped to a device, without touching the retention or the legacy commands. It is idempotent and batched so a client can send it after each local commit or once a minute.

Quick reference

Command Group22 (Beacon), proposed
Command Code3 (0x03)
Server functioncmd_beacon_ack in a new cmd_beacon.c (to be written)
Request bodyVariable: preamble(48) + N × 20-byte entries + terminator(2), N = 1–64. The count is derived from the body length (must be a whole number of 20-byte entries). Maximum 1330 bytes.
DevicePreamble byte 31 (Device ID), 1–255, chosen by the mailbox owner and carried inside the AN-authenticated, encrypted body. Zero is treated as 1.
Success responseStatus 250 with an encrypted N-byte payload: one result code per entry, in request order.
SemanticsRecords (mailbox, device, GUID, version) as delivered. Idempotent: repeating an Ack is a no-op that still returns success. Does not delete, does not shorten retention, does not affect other devices.
EncryptionRequired — AES-128-CTR, type 1, keyed by the caller’s selected coin AN for this RAIDA

Wire packet

QMail Ack request packet, bytes on TCP

+-------------------------------+-----------------------------------------+----------------------+
| RAIDA request header           | encrypted request body                  | plaintext terminator |
| 32 bytes                       | 48 + N*20 bytes                         | 2 bytes              |
+-------------------------------+-----------------------------------------+----------------------+
| command group=22, command=3    | challenge/CRC(16)                       | 3E 3E                |
| encryption type=1              | QMail identity/auth block(32)           |                      |
| body_size=50+N*20              | entry 1: email_id(16) version(4)        |                      |
| nonce(8)                       | entry 2 ... entry N                     |                      |
+-------------------------------+-----------------------------------------+----------------------+

The 32-byte request header is the standard header used by every QMail command (see the Ping page) with command group 0x16, command code 0x03 and a body size of 50 + 20N.

Request body (50 + 20N bytes)

Decrypted request body layout, N = 2 shown

+-------------------+--------------------------+------------------------+------------------------+------------+
| challenge/CRC     | QMail identity/auth      | entry 1                | entry 2                | terminator |
| 16 bytes          | 32 bytes                 | 20 bytes               | 20 bytes               | 2 bytes    |
| encrypted         | encrypted                | encrypted              | encrypted              | plaintext  |
+-------------------+--------------------------+------------------------+------------------------+------------+
| body[0..15]       | body[16..47]             | [48..67]               | [68..87]               | [88..89]   |
+-------------------+--------------------------+------------------------+------------------------+------------+
Body offsetSizeEncrypted?FieldDescription
0–1516YesChallenge / CRC32As for every QMail command.
16–3015YesSession ID, Coin Type, mailbox DN and SNThe mailbox whose Tells are being acknowledged. Same layout as Ping.
311YesDevice IDThe acknowledging device, 1–255. An Ack is scoped to this value; the same GUID acknowledged from device 1 is still returned to device 2. The shipped rest_core client writes 1 here already. Zero is treated as 1.
32–4716YesAuthenticity NumberThe mailbox coin AN for this RAIDA. Only the mailbox owner can acknowledge its Tells.
48 + 20k16Yesentry k: email_idThe Tell’s GUID (file header bytes 0–15 of the record received from Wait/Ping/Peek).
64 + 20k4Yesentry k: versionBig-endian. The publish_time field of the Wait cursor under which this Tell was received (bytes 2–5 of that cursor), or the Tell header timestamp (bytes 24–27) when it came from Ping/Peek. Identifies the published version: the server records an Ack against the retained version whose publish time is ≤ this value, and a later republication of the same GUID is a new version not covered by the Ack.
last 22NoTerminatorFixed 3E 3E.

Response packet

QMail Ack success response, bytes on TCP

+-------------------------------+-----------------------------------------+----------------------+
| RAIDA response header          | encrypted payload                       | plaintext terminator |
| 32 bytes                       | N bytes                                 | 2 bytes              |
+-------------------------------+-----------------------------------------+----------------------+
| status=250, group=6            | result[0] result[1] ... result[N-1]     | 3E 3E                |
| body_size=N+2                  |                                         |                      |
+-------------------------------+-----------------------------------------+----------------------+
Result byteMeaning
0x00Acknowledged (newly recorded).
0x01Already acknowledged by this device (idempotent repeat).
0x02Unknown GUID for this mailbox, or the retention window has expired. Nothing recorded; the client may drop its local pending marker.
0x03Version mismatch: the Tell has been republished since the version acknowledged. The Ack applies to the old version only; the new version will be delivered by Wait.

The whole request is authenticated once; individual entries never fail the batch. The status is 250 whenever the preamble validates, and per-entry outcomes are in the payload.

Semantics

  • Scope. An acknowledgement is a row (mailbox, device_id, email_id, version) in the beacon’s retention index. It has no effect on any other device and no effect on the file itself.
  • Retention. Unchanged. A Tell acknowledged by every known device still lives until its 30-day expiry. The amendment’s rule that fetch masks are not durable acknowledgements is kept: only an explicit Ack counts, and only for the device that sent it.
  • Effect on Wait. A Wait from the same device with flags.hide_acked omits acknowledged versions and does not count them in total_tells. A Wait without that flag, and any Ping or Peek, behaves as if no Ack existed.
  • Recovery. The acknowledgement rows are advisory. If the client loses its database it starts with a zero cursor and hide_acked = 0 and receives everything retained.
  • Timing. The client sends Ack only after its local database commit, never from a callback that precedes the commit. Sending it late is safe; sending it early loses mail on a crash.
  • Device retirement. A device that stops polling leaves rows that expire with the Tells they reference. No separate cleanup is needed.

Status codes

DecimalHexSymbolMeaning
2500xFASTATUS_SUCCESSRequest accepted; per-entry results in the payload.
50x05ERROR_INVALID_COMMAND_GROUPServer build has no group 22. The client stops sending Ack to this beacon for the session. A server with the group but without this command answers ERROR_INVALID_COMMAND (6).
80x08ERROR_COIN_NOT_FOUNDMailbox (denom, SN) is not loaded on this RAIDA.
160x10ERROR_INVALID_PACKET_LENGTHBody is shorter than 70 bytes, longer than 1330, or not 50 + 20N.
330x21ERROR_INVALID_EOFTerminator was not 3E 3E.
340x22ERROR_INVALID_ENCRYPTIONBody could not be decrypted or validated.
370x25ERROR_INVALID_CRCChallenge CRC mismatch.
1940xC2ERROR_FILESYSTEMRetention index could not be read or written. No entry was recorded.
2000xC8ERROR_INVALID_ANBody AN does not match the stored AN.

Compatibility

  • Existing clients: unaffected. They never send group 22 and never set hide_acked, so they keep seeing every retained Tell.
  • Existing servers: answer 5. The client treats that as “Ack unsupported here” and relies on local deduplication, exactly as today. Beacon Status reports support in advance.
  • Device identity: the Device ID is authenticated in the sense that only the mailbox owner (AN holder) can send it, but it is chosen by the client. Two installs that pick the same value share one acknowledgement view. The client must generate a stable per-install value; rest_core’s hard-coded 1 is not sufficient and must change before it uses Ack.
  • Legacy Ping/Peek: never consult Ack rows. This keeps the amendment’s promise that legacy Ping stays non-destructive and complete.
  • Storage: one small row per (device, Tell) in the retention index; bounded by 255 devices × retained Tells per mailbox and reaped with the Tell.

Common mistakes

Acknowledging from the receive callback

The callback runs before the database commit in rest_core. Send Ack from the code path that observes the commit, or on a timer that reads committed rows.

Expecting Ack to delete

It does not. Retention is a server policy; Ack only changes what this one device is shown by Wait with hide_acked.

Sharing one Device ID across devices

Two installs that both send device_id = 1 will hide each other’s deliveries once either acknowledges. Each install must choose a distinct, stable value; rest_core currently hard-codes 1 and must be changed before it uses Ack.