| D-M4-15-0 |
2026-10-16 |
Base int/m1-wave27 at 3d4f0f3, branch feat/M4-15-sync-outbox in its own worktree; the branch is not merged locally and not pushed, the PR lane lands it. The brief relies on no core rule or ruling that main has and the base lacks (D-W-100, D-W-114, D-W-115 and D-W-116 are on the base), so nothing is ported. The brief's 15-client-architecture.md and 04-sync-protocol.md are 15-client-architektur.md and 04-sync-protokoll.md on the base; the lines this ticket adds to 15 7.5 are English. The base has no section 10 in this file, so the rows stand in a section of the ticket. Differences to the brief's names: the display view is OutboxEntryView (OutboxEntry is the stored model of local_store), the classification result is Classification with OutcomeClass confirmed, retry, failed, conflict, the backoff function is fullJitterBackoff (M4-13 is not on the base). |
Instructions of the launcher, which override the brief's landing step; merged names win over the brief (D-W-100, D-W-114). |
| D-M4-15-1 |
2026-10-16 |
Outcome classes: a return of the executor is confirmed (2xx, 200 with Idempotent-Replay: true, 204). Network errors, timeouts, a cancelled request, 5xx other than 503 setup_required, 429 and 409 request_in_progress are retry with the same ID; 429 and 503 wait Retry-After when present, otherwise the full-jitter backoff. 412 is conflict: failed with the problem's current revision kept in conflict_current. Every other status (400, 401, 403, 404, other 409, 410, 413, 415, 422 including idempotency_key_reuse, which names a programming error, 428, 503 setup_required) is final with the problem code and reason. A DioException of type unknown with a 2xx response (a body the client cannot decode) is confirmed, since the server stored the write; one with a transport error inside is retry; any other error of an executor is final as executor_error, so a bug never loops. |
Design note 2 of the brief; 04 5, 04 13.2 items 4 and 5, 04 13.3. |
| D-M4-15-2 |
2026-10-16 |
Executor registry: OutboxExecutors.register(kind, executor, {lane, followUpEdit, followUpRemove}) is the only coupling to api_client; executors get the entry and an ApiCallContext whose extra carries the entry ID as Idempotency-Key. A kind without an executor fails the entry with no_executor. MessageOps and PersonalOps register the ten operations of the brief. |
Design note 3 of the brief: later tickets add their writes without changing the outbox. |
| D-M4-15-3 |
2026-10-16 |
One worker per FIFO lane; the lane is the thread (thread:<id>), or what the executor names for writes outside a thread (setting:<key> for own settings, message:<id> for reactions without a thread, upload:<id> for M4-22), else global. Lanes run in parallel; the HTTP client's connection limit per host bounds them, and the outbox has no number of its own. Only the oldest entry that is not final in a lane may go; a failed entry is skipped (it waits for the person), a dependency that is not confirmed blocks the lane. |
Design note 4 of the brief; 04 13.2 item 2. |
| D-M4-15-4 |
2026-10-16 |
Confirmation by echo: confirmByEcho(clientMsgId, result:) deletes the matching entry also while its request is in flight; the late response then finds no row and changes nothing (no second display entry, no confirmation hook). An unknown ID returns false. |
Design note 5 of the brief; 04 16.2, the echo can come first. |
| D-M4-15-5 |
2026-10-16 |
"Not sent yet" is attempts == 0. Only such an entry is edited or removed in place. An attempted entry (sending, waiting, or pending again after a restart) keeps its ID and payload; an edit queues a follow-up updateMessage and a removal a follow-up deleteMessage behind the newest entry of its follow-up chain, and a second edit changes a follow-up that has not been sent yet in place. An edit holds the changed members and is merged into the stored payload, so message_id and the slots the outbox fills stay. A failed entry edited or sent again becomes a new entry with a new ID; after a 412 the new entry carries the version of conflict_current as If-Match. All changes by the person and every step of the sender run under one lock, so an edit never changes a payload that is being sent. |
Design note 6 of the brief; 04 13.3 and 04 16.2 (a sent entry may be stored on the server). |
| D-M4-15-6 |
2026-10-16 |
depends_on holds the IDs an entry waits for, comma-separated (a message waits for two uploads; UUIDs contain no comma); the outbox selects the lanes itself and does not use OutboxDao.nextForThread, which reads the column as one ID. A confirmed entry leaves the table, so a missing dependency counts as confirmed; discarding an entry first fills the members its dependents hold as null from its own non-null members (a discarded follow-up edit hands the server's message_id to the delete behind it) and fails them with dependency_discarded, and replacing a failed entry with a new ID (retry, edit) re-points every entry that waits for it to the replacement through the new OutboxDao.updateDependsOn, so nothing is sent without what it needs. Within a lane a dependent never goes before its parent, also when a clock step gave it the older created_at. On confirmation the executor's result (for a message message_id and version) fills the payload members a dependent holds as null; a follow-up gets the server's ID of the message it edits this way. |
The schema of M4-12 has one depends_on column and no update of it; the brief needs several dependencies and follow-ups of entries whose server ID is not known yet. |
| D-M4-15-7 |
2026-10-16 |
"Sending delayed": an entry in waiting reports delayed once client.outbox_stuck_after has passed since it was written (created_at), not since its last state change; watchThread publishes again at that moment from the injected timer, without a database change. |
15 7.5; a backoff resets the state time on every attempt, so the state time would never reach the limit while the person waits. |
| D-M4-15-8 |
2026-10-16 |
packages/local_store gains one method, OutboxDao.updateDependsOn(id, dependsOn), with its test; no column and no migration change. Closing the outbox waits for the sender's running steps and leaves requests in flight alone, their entries stay sending and the next start sends them again with the same ID. A TLS error (HandshakeException, any IOException) is a transport error and retried. |
Findings of the code review: without re-pointing, a message whose upload was sent again with a new ID would go out before the new upload is complete (15 7.5); the brief allows local_store changes where the outbox needs a DAO method the base lacks. |
| D-M4-15-9 |
2026-10-16 |
The sender picks and claims the head of every idle lane in one section under the lock, on fresh data, so a change by the person or the outcome of another request never slips between the choice and the claim (dependencies and next_attempt_at hold). start() marks the outbox started before the first reset of sending entries and schedules only after it, so a pause() at any time holds. An edit of a failed entry drops the never sent follow-up edits behind it (they carry older texts); the entries behind them wait for the new entry. Discarding an entry fails its whole chain, not only the direct dependents, so no entry waits for a failed one and blocks its lane. Every dependency the outbox writes itself (the replacement of retry and edit, a follow-up of edit and remove, the bridge over a dropped follow-up) skips an entry that failed with its chain (dependency_discarded) and waits for what that entry waited for instead; an entry that failed for its own reason stays a dependency until it is retried or discarded; dependencies named in enqueue stay, so a message keeps waiting for its failed upload until the person retries or discards it. Reaction emoji are percent-encoded before they go into the path. |
Findings of the red team: a stale snapshot sent a message before its new upload and ignored a Retry-After; a pause during the first start was lost; an older follow-up overwrote the newest edit; a discarded create left its delete blocking the thread, and a retried delete waited for its chain-failed edit; # and ? emoji ended the path. |
| D-M4-15-10 |
2026-10-16 |
One resolver replaces the case patches of the dependency rule (lib/src/outbox/dependencies.dart, owner decision 2026-10-16). It reads every stored entry, final ones included, through the new OutboxDao.allEntries (the earlier lookups saw only threads with an open entry). Every row that leaves the table goes through it and re-points every entry that names it: a confirmed entry is dropped from the lists, a retried or edited one becomes its replacement, a dropped follow-up becomes what it waited for, a discarded one is dropped after its chain failed with dependency_discarded. So a stored depends_on names only stored entries, and a missing ID no longer counts as confirmed (this replaces that sentence of D-M4-15-6): a dependency gone without a recorded confirmation, checked again before every claim, fails the entry and its chain with dependency_discarded. enqueue resolves the IDs it is given through the replacements and confirmations of the running outbox; an ID gone before the outbox was created is unknown and fails the entry the same way, and sending it again sends it without that dependency. The replacement of retry and edit, the follow-ups of edit and remove and the bridge over a dropped follow-up resolve their dependencies with the rule of D-M4-15-9 (a chain-failed parent is replaced by what it waited for, a discarded one is dropped). The lane order stays FIFO except that an entry goes no later than the oldest entry that waits for it, also through other lanes, and a parent before its dependent; a replacement at the end of a lane otherwise waited for good behind an entry that waited for it through another lane. test/outbox/dependency_model_test.dart, a seeded randomized model test (enqueue with dependencies, success, own failure, retry class answers, held answers, echo, retry, edit, remove, discard, time and restarts across threads and upload lanes), checks after every step that nothing is sent before what it depends on, that no open entry waits for a gone ID that was not confirmed, and that the outbox empties once every failed entry is retried or discarded; it was red on 2e616d7e (seed 1). |
Owner decision 2026-10-16: five reproduced Majors of one class (an entry sent before its dependency or waiting for good after failures, retries, edits, discards or removes); fix the class at one place and prove it with an invariant test. |
| D-M4-15-11 |
2026-10-17 |
The ticket was ported from its integration base onto main with only its own commits; the English chapter 15 takes the two outbox transitions and the paragraph on 409 request_in_progress and 412 version_conflict (section 7.5), and the section stands before M4-01 instead of at the end of this log. |
Port onto main (PR lane); M4-12 and M4-16 are on main or land before it. |