feat(web+api): the commit loop dead-ends at baseline v1 — no Amend reason, no changeset, no second exit

#3129 (closed) builds the first arrow of UX-REVIEW §4 (draft ──Commit plan──▶ baseline v1) and then says, correctly, that "the Amend/changeset half of case 14 is explicitly out of scope here." Nothing tracks that half. This issue is its landing place, so the deferral is a scope decision on the record rather than a sentence inside a sibling issue that closes on its own acceptance criteria.

The loop UX-REVIEW §4 specifies, in full

draft ──Commit plan──▶ baseline v1

                          ├─ every structural edit asks for one line of "why"
                          │  and joins an open CHANGESET

              "Review 3 changes" (context bar, primary)

              ┌───────────┴────────────┐
      Take baseline v2          Keep v1 · let variance stand

#3129 (closed) delivers the top arrow only. Everything below it is unbuilt on both sides.

What exists, and what does not

Exists (server): apps/projects/commit_moment.py; Baseline (models.py:4167) and BaselineTask (:4232); BaselineSerializer / BaselineDetailSerializer (serializers.py:5902,5939); BaselineViewSet (views.py:6898) and BaselineActivateView (:7068). Baseline history is already a list, which satisfies the handoff's "v1 is kept forever".

Does not exist, anywhere:

  • No amendment record. grep -rn "changeset\|amend_reason\|rebaseline" packages/api --include='*.py' returns only ADR-0104 signal-privacy hits — a different subsystem entirely.
  • No per-structural-edit reason prompt on the client.
  • No Review N changes affordance, and no context-bar mode for it.
  • No second exit. BaselineActivateView can make a new baseline active; nothing offers "Keep v1 · let variance stand" as a coequal choice.

The part that is a product commitment, not a feature

UX-REVIEW is explicit about why there are two exits:

Two exits, deliberately. Take baseline v3 and Keep v2 · let variance stand, with a note that re-baselining is for a plan that was re-agreed, not for turning a red number green. A product that only offers re-baseline teaches people to launder slip.

That is the load-bearing sentence in §4 and it must not be dropped when this is scoped down. A changeset UI whose only button is Take baseline v(n+1) is worse than no changeset UI, because it makes laundering the path of least resistance and puts the product's name on it.

Relationship to #101 — adjacent, not the same

#101 (Baseline management — create, name, compare, milestone 0.5) covers baseline capture, comparison, variance trending and a re-baseline request with justification that the PM can self-approve in OSS. That is a governance/approval model.

This is the authoring model: a reason attached to each structural edit as it is made, that accumulates into an open changeset, reviewed as a set, with re-baseline as one of two exits. Different trigger, different granularity, different artifact. They should agree on the Baseline object and on vocabulary, and #101 should not be treated as covering this.

The prototype answers most of this — transcribe, do not redesign

design_handoff_trueppm_v4/proto-designer.js implements the whole loop. The four questions this issue originally listed as open are answered there; only the server model is not.

What counts as "structural"amendGuard(kind, fn) (:339-343) wraps exactly three kinds, not the full session-trail set: add (6 call sites), move (8), delete (2). It is a pass-through when S.state !== 'active', so the prompt exists only after baseline v1.

Is the reason required? Yes, and the refusal has copy (:1290-1292):

A reason is required — it goes to the people who planned around this work.

Free text, no category picker, Enter submits, Cancel aborts the edit entirely. The hint under the field states where it travels: "Goes into plan history, into the notice, and into the changeset you re-baseline from."

The three sheet states are one function with three returns:

  • Draft (:984-992) — Commit & take baseline v1, with the line that names the mode change: "This is not a lock — it is how editing works from here. Author becomes Amend."
  • Baselined, nothing pending (:996-1001) — quiet Baseline vN opening history: "To change the plan, just change it. Every structural edit asks for one line of why."
  • Baselined, changes pending (:1004-1025) — Review N changes, both exits as real buttons: Keep vN · let variance stand (ghost) and Take baseline vN+1 (primary), plus the anti-laundering note: "Re-baselining is not the only honest answer… Re-baseline when the plan itself was re-agreed, not to make a red number go green."

Session trail vs changeset — they coexist. S.amendments and S.pending are separate from the trail, and each pending entry carries {text, kind, was, now, at}: the reason, the kind, and the finish date before and after that single amendment. That is what makes the per-amendment date effect in the acceptance criteria computable.

Context bar (:569-578) switches on the same state: Commit plan in draft, then Review N changes or a quiet Baseline vN, with a Health · vs vN cluster replacing State · Method once baselined.

Design resolved — §C, decisions D9–D11

handoff-0.4-open-three/Open Questions 0.4.html §C settles the interaction. §4's requirement is right; the prototype's implementation of it is not. amendGuard holds the drop until a modal is satisfied, which produces three defects with one cause:

  • The card is not where the hand left it — Cancel returns it to the origin, an outcome the gesture already showed as complete.
  • It fires per drag. dropCard is routine Board work (across a column sets status, across a lane reparents). Ten cards is ten modals.
  • It contradicts the tree's own model — the server always has the last word, client compute is preview. Everywhere else the optimistic result lands and the server corrects it; here a local dialog outranks both.

Cause: a reason is being treated as a precondition of the edit. It is a property of the changeset entry — and the entry exists the moment the edit is applied.

D9 — no gesture waits for a reason

On pointerup the drop applies, reschedules and goes to the server exactly as in draft. The amendment enters the changeset immediately with reason: null. Direct manipulation is never gated — including delete, which lands with an Undo action toast rather than a pre-modal.

pointerup ─▶ drop applies ─▶ reschedule ─▶ server preview        (unchanged from draft)

                 └─▶ changeset entry { kind:'move', reason:null, group:'sess-8f' }

        ┌─────────────────┼──────────────────────┐
   answer now        keep working            answer later
   (strip, ⌥W)   (group absorbs the next    (changeset sheet,
                  moves of the same kind)     inline per row)

                     re-baseline is blocked until every entry has one;
                     "Keep v1 · let variance stand" is not.

D10 — captured after, in place, never modally

One line docked in the Forecast strip. It never autofocuses after a pointer gesture (the hand is still on the object) and is one Tab or ⌥W away for the keyboard. Four states:

  1. Immediately after a drop — stated, not demanded: Switchgear PO moved into Commissioning · finish Nov 4 → Nov 11 + Why? ⌥W
  2. Four more drags — one entry, one reason: 5 cards moved in this session, listing the rows, with Why these moved over a single field
  3. Answered — collapses to reasons up to date in the strip
  4. Left unanswered — persistent and non-blocking: 2 changes need a reason

The bar states what happened in the system's own words — that is the what, and it is never written into the reason field. Only a person writes the why. Esc dismisses the bar; dismissing does not answer it and the entry stays marked.

What a session is (the batching rule)

One changeset entry absorbs subsequent acts when all hold:

Condition Rule
Same kind move groups with move. add and delete each group with their own; kinds never mix
Same surface Board drags do not group with Grid restructures — a reason that covers both is a reason that covers neither
Same author Multi-author safe: another person's amendment opens its own group and never joins yours
Within 2 minutes Of the previous act in the group

The group also closes on: answering it, opening the changeset, changing mode or surface, or unload. A closed group is immutable — a later move opens a new entry rather than reopening a reasoned one. Undoing one act decrements its count and its date effect; undoing the last one deletes the entry, reason and all.

D11 — what an unanswered reason does

To… Effect
the plan Nothing. The edit is applied, scheduled and server-confirmed like any other
variance Nothing. Variance reads from dates, not from prose
the changeset Entry present with reason: null, sorted to the top, marked needs a reason, counted in the context bar (Review 5 changes · 2 need a reason)
re-baselining Blocked. Take baseline v2 is disabled while any entry is unreasoned — §4's constraint enforced at the one moment it means something. Keep v1 · let variance stand stays available: living with variance is not a claim about why
other people Plan history shows the amendment with no reason given yet; owners of affected work are notified once, on the change — not again for the missing line
auto-fill Never. An auto-filled reason is worse than a blank one — it looks answered
expiry None. Unreasoned entries do not age out and nothing is discarded for lack of a reason

Strings

Key String
amend.bar.what.move {name} moved into {phase} · finish {was} → {now}
amend.bar.what.group {n} cards moved in this session
amend.bar.prompt Why?
amend.bar.prompt.group Why these moved
amend.pending.count {n} changes need a reason
amend.blocked.rebaseline {n} changes need a reason before this plan can be re-baselined.
amend.history.none no reason given yet

Considered and rejected (do not re-propose)

Option Why not
Keep the modal, exempt the Board Two rules for one act. A row drag in Grid and a card drag on the Board are the same structural edit
Reason at review time only Loses the moment — by the time five moves are listed, "why" is reconstruction. Stays available as the fallback path, not the only one
Non-modal capture, but blocking the drop until answered Same defect in a smaller box. The drop is what must not wait
Apply, then revert if no reason within N seconds A plan that silently un-edits itself — worse than the snap-back, because it happens after the user looked away
Category picker instead of free text §4 settled this. A dropdown makes the cheapest option the most common answer

What is still open — the backend

The design explicitly does not spec the backend: "The changeset already carries per-entry reasons; what is new is that reason is nullable and that re-baseline validates it."

So the server work still to decide:

  • Where the changeset lives — rows on Baseline (models.py:4167) or its own model. Nothing exists today; this decides whether an ADR is needed.
  • reason nullable, with re-baseline validating that no entry is unreasoned — the server must own that gate, not just the disabled button (ADR-0599).
  • The grouping key (kind + surface + author + 2-minute window) has to be computed somewhere and survive a reload; a client-only group id does not.
  • Whether the amendment reason is subject to the same RBAC band as the edit it annotates.

Acceptance criteria

  • No structural gesture is gated on a reason — drag, delete, indent, group and their keyboard twins all apply on commit and reach the server as in draft
  • Delete lands with an Undo action toast, not a pre-modal (depends on #3149 (closed)'s action slot)
  • The changeset entry is created with reason: null at the moment the edit applies
  • The reason bar is non-modal, docked in the Forecast strip, never autofocuses after a pointer gesture, and is reachable by ⌥W or one Tab
  • Batching implements all four conditions and all four close triggers; a closed group is immutable
  • Undoing the last act of a group deletes the entry and its reason
  • An unreasoned entry blocks only re-baselining; Keep v1 · let variance stand stays enabled, and the server enforces the same gate
  • Unreasoned entries sort to the top of the changeset, are answerable in place, and are counted in the context bar
  • Reasons are never auto-filled and never expire
  • pytest on the changeset write path, the nullable reason, and the re-baseline validation; vitest on the reason bar and the batching rule; Playwright covering drop → unreasoned entry → answer inline → re-baseline, and the blocked-re-baseline path
  • docs/ updated — this is user-visible governance behavior

Gates

architect (the backend questions above), ai-review, threat-model (a user-authored reason string on a governance artifact), then ux-review, accessibility, and the pre-MR cluster.

Depends on #3129 (closed) (no baseline to amend until commit has a caller), #3127 (closed) (nothing is a draft yet) and #3149 (closed) (the action slot the delete path lands in).

Source: handoff-0.4-open-three/ §C + D9–D11; original loop design_handoff_trueppm_v4/UX-REVIEW.md §4 and README.md case 13/14.

Edited by Kelly Hair