Skip to content

feat(mission-control): an opt-in decision revision on the settling verbs - #2448

Merged
mvschwarz merged 6 commits into
mainfrom
fix/decision-revision-guard
Nov 3, 2026
Merged

mvschwarz merged 6 commits into
mainfrom
fix/decision-revision-guard

Conversation

@mvschwarz

@mvschwarz mvschwarz commented Nov 3, 2026 •

Copy link
Copy Markdown
Owner

What a person gets

A client, such as an outside GUI, can settle exactly the decision its person reviewed. It reads the decision's revision, and sends that revision with the answer. If the question or the decision changed in between, nothing is written: the client hears that it changed and gets the current state.

It's opt-in. A caller that sends no revision acts exactly as before, with the same result fields. The CLI, TUI and Slack don't send one.

What changed

  • GET /api/queue/:qitemId gains decisionRevision. It's an opaque token, computed on read and never stored, covering what a person decides on:
    • The question: the body, the ask's intent and its questions (ids, text and options).
    • The decision's state: open, blocked or terminal; who it's addressed to or parked on; the recorded answers; a decision made on the person's behalf; the current park; and the replies returned.
    • Left out: a summary change, a wake or a claim doesn't move it. A new park does, since that's a new decision.
  • POST /api/mission-control/action takes an optional expectedRevision for answer, resolve, approve and deny:
    • When it's current, the act runs as today, and the result adds decisionRevision, the revision as saved.
    • When it's stale, the response is 409 decision_revision_changed, and nothing is written. That means no answer, closure, reply, transition, audit row or wake. The response carries currentRevision, the row's state and answers, and its most recent action.
    • On any other verb, it isn't checked. The act runs as today, and the result's revisionNote says the revision wasn't checked.
  • A retry is reconciled. A client can put an operationId in the existing auditNotes.
    • A retry that finds the decision already changed by that same operation is answered from its record, with reconciled: true and the original actionId. Nothing is written again.
    • "That same operation" means the same id, verb, sender and payload. A different verb or payload under the same id still conflicts.
  • Where the compare runs: at the start of the act, before any branch that writes, including the ones that finish an already-answered ask. No await separates the compare from the act's first write, so no other request in the daemon can change the decision in between.
    • A runner's permission ask has no reply row. So an answer that changed nothing before its await has its first write still ahead. That's typed words, or a choice whose answer set was already complete. It compares again just before its close. A choice that recorded its answer was compared before that write.
  • A new park is a new revision, on any blocker. That includes a typed gate parked again on the same gate after it was resolved. A note on a parked row doesn't change it.
  • Registered hosts: a forward to the origin host carries expectedRevision, which is compared there. Forwarding still strips authority and the claimed actor, as before.

Tests

The red commit adds only decision-revision.test.ts. It runs on real SQLite with all migrations, the real queue, the Mission Control route and its write contract, and the single-row read.

  • An unchanged revision acts, and returns the saved revision and row.
  • A stale revision conflicts with nothing written, for:
    • an answer and a deny, after the question text changed under the same ids;
    • an approve, after another person recorded a partial answer;
    • a resolve, after a re-park on another person.
  • Of two concurrent answers on one revision, one commits and one conflicts. Only one answer is recorded.
  • Of two concurrent typed answers to a runner's permission ask on one revision, one closes it and one conflicts. One audit row is written.
  • On a runner's permission ask whose answers were already complete, a guarded choice conflicts when a deny lands during its return, and only the deny is recorded.
  • A choice on a runner's permission ask that records its answer closes the ask.
  • A typed-gate park that's resolved and then parked again on the same gate conflicts with the old revision. A note on the park leaves the revision unchanged.
  • A caller that sends no revision acts as before. The tests pin the exact result fields for answer and for approve.
  • A retry of the same committed approve is reconciled. A deny, or a different reason, under the same operation id conflicts. One audit row remains.
  • A revision on hold isn't checked: the hold runs, and the result says the revision wasn't checked.
  • A summary, a wake or a claim leaves the revision unchanged, and an act on the original revision still runs.
  • The route passes the revision to the write contract. A registered-host forward carries it to the origin. These two cells stub the contract and the fetch.

Not covered

  • Other reads: the Mission Control views and attention reads don't carry the token yet. They can follow when a client needs them.
  • A retry of an operation that changed nothing: for example, re-recording an answer that's already saved. The revision stays the same, so the exact retry acts again and writes a second audit row. Reconciliation only runs when the revision has changed, as fitted.
  • Forwarded authority: a forwarded act still runs as this daemon's identity at the origin. Carrying a person's authority across hosts isn't part of this change.
  • Native runs: none. The concurrency cell runs two requests in one process, which is the daemon's own shape.

Cost

Source +90 across four files, comments included. Tests +239.

Refs #2446

🤖 Generated with Claude Code

Tests only (#2446). A client reads an ask's decisionRevision from the single-row
queue read, and an answer, resolve, approve or deny sent with it acts only on
that decision:

- an unchanged revision acts and returns the saved revision and row;
- a stale one (changed question text under the same ids, another person's
  partial answer, a re-park) conflicts with nothing written;
- of two concurrent answers on one revision, one commits and one conflicts;
- a retry of the same committed operation is reconciled from its record, and
  another verb or payload under the same operation id is not;
- a revision on a verb that doesn't settle a decision is refused;
- a summary, a wake or a claim leaves the revision alone;
- the route passes the revision to the write contract, and a registered-host
  forward carries it to the origin.

A caller that sends no revision keeps today's behaviour and result fields.
@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Caution

Review failed

The pull request is closed.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.

Refs #2446. GET /api/queue/:qitemId carries decisionRevision, an opaque hash of
what a person decides on: the body, intent and questions, and the decision's
state (open, blocked or terminal; who it's addressed to or parked on; its
answers, a decision made on their behalf, the current park and the replies
returned). A summary, a wake or a claim doesn't change it.

POST /api/mission-control/action takes an optional expectedRevision on answer,
resolve, approve and deny. The compare runs at the start of the act, before any
branch that writes, with no await before the first write. A stale revision is
409 decision_revision_changed with nothing written, and the current revision,
state and latest action. A retry of the caller's own committed operation
(the same operationId in auditNotes, verb, sender and payload) is answered from
its record. On other verbs it's 400 decision_revision_unsupported. Without it,
every act and result is as before. A registered-host forward carries it to the
origin.
dev-driver added 4 commits November 3, 2026 15:06
… gate, and an unchecked revision

Tests only, #2448's delta:

- two concurrent typed answers to a runner's permission ask on one revision: one
  closes it and the other conflicts, with one audit row;
- a typed-gate park resolved and parked again on the same gate conflicts with the
  old revision, while a note on the park leaves the revision unchanged;
- a revision sent with hold isn't checked: the hold runs, and the result says so
  (replacing the earlier refusal).
… new park is a new revision, and an unchecked revision is noted

#2448's delta (#2446):

- A typed answer to a runner's permission ask writes nothing until its close,
  after an await, so a guarded answer compares its revision again just before
  that close, with no await in between.
- The revision includes the transition that entered the current park, so a
  typed-gate park resolved and parked again on the same gate is a new revision.
  A note on a parked row doesn't change it.
- A revision sent with a verb that doesn't settle a decision is no longer
  refused: the act runs as today, and the result's revisionNote says it wasn't
  checked.
…ts when a deny lands during its return

Tests only, #2448 round 3. A runner's permission ask still pending with its
answers already recorded (an interrupted return): a guarded choice writes nothing
before its return, a deny lands during it, and the choice must conflict with only
the deny recorded. A control pins that a choice which records its answer still
closes the ask.
… the call wrote nothing first

#2448 round 3. On a runner's permission ask (no reply row), the re-compare before
the close now keys on whether this call changed the ask before its await, not on
choice versus typed. A choice whose answer set was already complete (an
interrupted return) writes nothing, so it compares again like typed words. A
choice that recorded its answer was compared before that write and keeps its
behaviour, so the nothing-was-written conflict stays true.
@mvschwarz
mvschwarz marked this pull request as ready for review November 3, 2026 17:37

@openrig-review openrig-review left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at aebb57c. An opt-in decision revision on the four settling verbs: GET /api/queue/:qitemId returns decisionRevision, and answer, resolve, approve and deny take an optional expectedRevision. A stale one answers 409 decision_revision_changed with nothing written; a caller that sends none acts exactly as before. The compare runs before the act's first write, with no await between, and again before a runner ask's close when the call wrote nothing first. Required CI passes at this head.

@mvschwarz
mvschwarz merged commit e7e6dc4 into main Nov 3, 2026
15 checks passed
@mvschwarz
mvschwarz deleted the fix/decision-revision-guard branch November 3, 2026 17:38
@github-project-automation github-project-automation Bot moved this from Todo to Done in OpenRig roadmap Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants