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 changesaffordance, and no context-bar mode for it. - No second exit.
BaselineActivateViewcan 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 v3andKeep 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) — quietBaseline vNopening 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) andTake 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.
dropCardis 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:
- Immediately after a drop — stated, not demanded:
Switchgear PO moved into Commissioning · finish Nov 4 → Nov 11+Why? ⌥W - Four more drags — one entry, one reason:
5 cards moved in this session, listing the rows, withWhy these movedover a single field - Answered — collapses to
reasons up to datein the strip - 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. reasonnullable, 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: nullat 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
⌥Wor oneTab - 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 standstays 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.