ADR-0045: A message id is used once in a workspace¶
Status: Accepted Date: 2026-09-29 Deciders: Alex Nodeland
Context¶
PostMessage carries a message_id, but core never checked it, so a repeated post appended a second message and, through the runner, started or steered a second turn (#61). The runner's memory of command_ids (ADR-0022) lives in one process and forgets. stackr RFC-0002 posts messages from reflexr runs, which retry, and counts invalid_state on a create with its own id as done. Messages are not entities (ADR-0037), so core had nothing to check an id against without reading the log. Every other command a client may retry is already refused durably or repeats as a no-op, except give_feedback, which has no id and which RFC-0002 doesn't need.
Decision¶
- A used message id is refused with
InvalidState("message m1 already exists"), in any thread of the workspace, as duplicate artifact, proposal and thread ids are. Nothing is appended, andRunner.sendraises before it acts, so no second turn starts. - Core decides, through the
needsloop (ADR-0018).needs(PostMessage)asks for the message id; the host loadsState.messages, whether each id is used; andCommitResult.messageslists the ids to record as used. - Storage keeps only the ids.
InMemoryStoragekeeps a set per workspace. SQL storage keeps a table,artifactr_messages, keyed by tenant, workspace and id; migration 0003 fills it from the storedmessage_postedevents.
Options considered¶
| Option | Trade-off |
|---|---|
Refuse, with ids loaded through needs (chosen) |
Like every other create; the rule stays in core and its fixtures, and a check is one key lookup |
| Return the existing message | Unlike every other create. The runner would need an "already posted" flag not to start a second turn, and a different message with a reused id would be dropped silently |
| Look the id up in the workspace, outside core | A rule outside core and its fixtures |
| A unique constraint in SQL alone | In-memory storage would differ, and PostgreSQL aborts the transaction on the violation |
| Scan the thread's events for the id | The cost grows with the thread |
Consequences¶
- Easier: a caller that chooses its message ids posts each message once, across retries, processes and restarts.
- Harder: a
Storageimplemented elsewhere must loadNeeds.messagesand saveCommitResult.messages; one that doesn't fails withNotLoadedon the first post. - If a process dies between posting a message and starting its turn, a retry is refused and the turn never runs (#68).