Status: accepted, 2026-10-22. Issue: #24.
Context
ROADMAP 0.2 sketched Ramal.propose/2 as "a branch becomes a proposal record (ramal_proposals) that someone approves
(which merges) or rejects (which discards), with the diff and the staleness check stored at decision time". A proposal
has exactly one branch, and a branch has at most one proposal. The decision has to be recorded in the same step that
marks the branch merged, or a crash between the two leaves an approved proposal whose branch is still open.
Decision
- No new table, no new storage callbacks.
Ramal.Branchgets two statuses,:proposedand:rejected, and aproposalfield:%{proposer, reason, proposed_at, decision}withdecisionnilor%{status, by, reason, at, replayed, stale}.Ramal.Storage.Codecstores it in the existingstatedocument (written field by field, no new atoms, actors without their sensitive attributes as in ADR 0010); thestatuscolumn gains two values. State version stays 1: a branch stored by 0.1.0 has no"proposal"key and loads withnil. propose/2freezes an open branch:Server.propose/2applies the change and persists before replying. A proposed branch is not open, so reads, writes, forks, merges and a second proposal return:not_open;diff/1,get/1andlist/2keep working.approve/2isRamal.Merge.runon a:proposedbranch (the merge claim takes the status it expects). The decision, with the replay count and the stale rows the merge let through, is added by the same server call that marks the branch:merged, so the two cannot be separated. Failure leaves the branch:proposed.reject/2records the decision, sets:rejected, persists and stops the process. Overlay and log stay, because the rejected diff is the audit trail;discard/1does not apply to it.- Withdrawing is
discard/1on a proposed branch. - Authorization is the host's.
:decided_byis recorded, never checked. Ramal has no notion of a user, and a check callback would be a plugin point with no second use. - The diff is not copied. A proposed, merged or rejected branch is frozen, so
diff/1of the stored branch is the diff at decision time. (After a merge,diff/1compares against snapshots taken when the branch wrote, not against the base as merged.)
Consequences
- Listing pending proposals is
list(domain, status: :proposed); there is no separate query, filter by proposer or pagination. A host that needs them reads the stored branches. - A rejected branch keeps its overlay and log in storage until the host deletes the row; a discarded one does not.
- Two deciders racing: the merge claim and the process serialize them. The loser gets
:not_open. - "Several approvers" or "request changes" are not modelled. They would be a new ADR with a reason from a real host.