§0a captures m's locked picks across all 8 questions. Two divergences from
inventor recommendations reshape the model:
- Q4: hybrid — approver edits the proposed values (counter-payload) AND/OR
leaves free-text in decision_note. Adds counter_payload jsonb column.
- Q6/Q7: the counter is treated as a NEW pending approval_request authored
by the approver, not an "edit and resubmit" CTA on the requester side.
Original requester sees the old row as changes_requested ("Abgelehnt mit
Vorschlag") and the new row as pending — they can approve it themselves
if eligible (they're no longer the requested_by). 4-Augen still holds.
§3 implementation sketch rewritten: SuggestChanges atomically closes the
old row, applyRevert's the entity, spawns a new pending row with
counter_payload as payload + previous_request_id linking back, re-applies
the counter via write-then-approve, emits both *_approval_changes_suggested
and *_approval_requested events. Migration 103 adds the CHECK value plus
counter_payload jsonb + previous_request_id FK + index. Slice plan trimmed
to backend / frontend-modal / Verlauf-integration.
26 KiB
Design — "Suggest changes" action on approval flow
Author: hertz (inventor)
Date: 2026-05-19
Task: t-paliad-216 (m/paliad in-flight)
Branch: mai/hertz/inventor-suggest-changes
Status: DESIGN — open questions await m before any coder shift.
0. TL;DR
Add a fourth action "Änderungen vorschlagen" ("Suggest changes") to the approval flow, alongside Approve / Reject / Revoke. Use case: the approver doesn't want to accept the proposed change as-is, but doesn't want to reject outright — they edit the proposed values into a counter-proposal and submit it back into the same approval flow.
Mental model (m, 2026-05-19): suggest-changes is not "ping the requester to fix it" — it's the approver authoring a counter-proposal that gets re-injected into the approval flow as a fresh pending row. The original requester (now potentially an eligible approver of the counter, since they're no longer the requested_by) sees:
- the old row in their /inbox as
changes_requested("Abgelehnt mit Vorschlag" / "Declined with changes") — historical record of their original attempt; - the new row in /inbox as
pending— the counter, which they can approve, reject, revoke (n/a, not theirs), or suggest changes back on. Everyone else eligible sees the new row too. 4-Augen still holds: the counter's requested_by (the approver who suggested it) cannot self-approve.
Click flow:
- Approver opens an editable modal on the pending row showing the requester's proposed values. Edits any field. Writes a free-text note ("Bitte den Termin um 9:00 statt 8:00, weil der Raum sonst kollidiert").
- POST
/api/approval-requests/{id}/suggest-changeswith{note, counter_payload}. - Server, in one tx: closes the old row (
changes_requested,decision_note=note), reverts the entity frompre_image, then immediately inserts a newpendingapproval_requests row authored by the approver withpayload=counter_payload, re-applies the counter to the entity, markspending_request_idto the new row, emits two events (*_approval_changes_suggested+*_approval_requested).previous_request_idFK links new → old for chain traversal.
The pending audience for the new row is the same as any fresh Submit* — the existing notification + visibility plumbing handles it without special-casing.
0a. m's decisions (2026-05-19)
| # | Header | m picked | Reasoning note (when different from recommendation) |
|---|---|---|---|
| Q1 | State machine | (a) New status changes_requested. |
As recommended. |
| Q2 | Entity state | (a) Reverts to pre_image, same as Reject. | As recommended. The counter is then re-applied in the same tx by the new approval row's write-then-approve cycle. |
| Q3 | Chain depth | (a) Yes, across chained rows. | As recommended. |
| Q4 | Note shape | Hybrid: approver can edit the proposed values (counter-proposal) AND/OR leave free-text in decision_note. |
Differs from (a). Inventor picked free-text-only; m's twist: the suggestion should ALSO carry concrete edits. This adds a counter_payload jsonb column on approval_requests and turns "suggest-changes" into an action that authors a real counter-proposal, not just a hint. |
| Q5 | Surface | (a) /inbox only — v1. | As recommended. Email + entity-detail badge are Phase 2. |
| Q6 | Requester actions | Different model: the counter is a NEW pending approval_request row, not an "edit + resubmit" CTA on the requester side. | Differs from (a). m's reframing: instead of routing back to the requester to act on, the suggestion IS the next request. Original requester sees the old row as changes_requested (status pill "Abgelehnt mit Vorschlag" or similar). Original requester then sees the NEW row in /inbox like any pending — and may approve it themselves, because they are no longer the row's requested_by (the suggesting approver is). Everyone else eligible sees it too. Cleaner workflow, removes the "edit-and-resubmit CTA" from the requester role entirely. |
| Q7 | Notifications | (b) Notify all eligible approvers + the original requester for the NEW pending row. | Consistent with Q6. The counter is a fresh pending request, so the existing Submit*-notification audience applies. The original requester needs the ping because they're now an eligible approver of the counter — no special-case path. |
| Q8 | Audit shape | (a) New event_type *_approval_changes_suggested per entity. |
As recommended. The new row also emits a normal *_approval_requested event, so the Verlauf chronology naturally captures the chain. |
The decisions above lock the design. §3 has been rewritten to reflect them; §2 (open questions) is retained as the historical record of what was open before the decisions.
1. Context — what's already in the code (verified 2026-05-19)
- State machine in
internal/services/approval_service.go:paliad.approval_requests.statusCHECK is already('pending', 'approved', 'rejected', 'revoked', 'superseded')— thesupersededvalue is defined as a Go constantRequestStatusSupersededbut never written by the live service (reserved).paliad.{deadlines,appointments}.approval_statusCHECK is('approved', 'pending', 'legacy')— three values only.- Shared kernel
decide(requestID, callerID, finalStatus, note)powers Approve / Reject / Revoke. Approve invokesapplyApproved; Reject + Revoke invokeapplyRevert(restores entity frompre_image). - Self-approval blocked at 3 layers:
canApproveGo gate,approval_requests_no_self_approvalDB CHECK, deadlock-check excludes requester from pool.
- Handlers in
internal/handlers/approvals.go:POST /api/approval-requests/{id}/approvePOST /api/approval-requests/{id}/rejectPOST /api/approval-requests/{id}/revokeGET /api/approval-requests/{id}— single hydrated request
- Per-viewer flags (t-paliad-202, shipped): every row carries
viewer_can_approve+viewer_is_requesterresolved server-side so the UI can grey out buttons the server would reject. Server still enforces — the flags are a UX hint. - Frontend:
frontend/src/client/inbox.tswires three buttons per pending row (approve/reject/revoke). Reject openswindow.prompt()for the note; approve+revoke don't.frontend/src/client/views/shape-list.ts(row_action="approve") stamps the row with action buttons + diff +decision_notedisplay if present.
- Audit: event types
*_approval_requested,*_approval_approved,*_approval_rejected,*_approval_revokedemitted topaliad.project_events(one per entity_type prefix). - Decision note:
paliad.approval_requests.decision_note text— a single free-text column, last-write-wins. Already populated on Reject (Approve also accepts an optional note).
2. Design questions (the open list — see §6 for answered)
Pre-recommendations from inventor. m will pick via AskUserQuestion.
State machine
Q1 — Where does "suggest changes" sit on the lifecycle?
- (a) New status
changes_requested(RECOMMENDED). The approval_requests row transitions pending → changes_requested. Sibling of approved/rejected/revoked/superseded. The row is terminal in that status; a re-submit creates a fresh row (linked viaprevious_request_id). - (b) Reuse
rejectedwithis_revisable=trueflag. Cheap, but conflates two semantically distinct outcomes ("we'll never want this" vs. "tweak X and try again"). - (c) Auto-revoke the current row, mark the entity for edit, requester creates a new approval row when ready. Reuses existing plumbing — but loses the approver's note as a first-class thing (it'd just be a comment on the project_events row).
- (d) Other (you'll tell us).
Recommend (a) — keeps the audit lifecycle clear, gives us a clean place to hang the suggestion note, and is the smallest schema change (one new value in a CHECK constraint).
Q2 — What happens to the entity (deadline/appointment) while in "changes requested"?
- (a) Entity reverts to pre_image — same as Reject (RECOMMENDED). approval_status flips back to
approved. The requester edits the entity in the normal flow; saving fires a freshSubmit*cycle. - (b) Entity stays at
approval_status=pendingcarrying the proposed values; requester edits "in place" through a new "amend the pending request" endpoint that mutates the same approval_request row + entity fields. - (c) Entity goes to a new
approval_status=draft(would require a new value on the entity-level CHECK + UI work to handle a third entity state).
Recommend (a) — minimum schema change, reuses every existing path (entity edit, Submit*, applyRevert, project_events emission). The trade-off is one extra approval_requests row per cycle; we link via previous_request_id so the chain stays inspectable.
Q3 — Can the approver suggest changes multiple times (across a chain)?
- (a) Yes, across chained rows (RECOMMENDED). Each row is terminal after suggest-changes; the requester resubmits → new pending row → approver can suggest changes again. Chain depth unbounded.
- (b) No — one chance per entity-lifecycle; if the requester comes back, the only options are approve or reject (the suggest-changes button is hidden for the second submission).
Recommend (a) — bounded by the requester's patience, not by the system. Multi-round review is the norm in legal-doc workflows.
Q4 — Note shape on the suggestion
- (a) Free-text — reuse
decision_note(RECOMMENDED). Same column the existing Reject path already populates. Last-write-wins per row (but rows are terminal after suggest-changes, so there's no real "last write"). - (b) Thread of notes — new
paliad.approval_notestable, ordered, multi-author. Lets the requester respond inline, the approver clarify, etc. - (c) Structured per-field suggestions (
[{"field": "due_date", "current": "...", "suggested": "..."}]) — a "diff-style" view.
Recommend (a) — matches the existing Reject UX, no new schema. (b) is right if the team wants to discuss; (c) is over-engineered for v1.
UX
Q5 — Where does the requester see the suggestion?
- (a) /inbox under
a_role=self_requested(RECOMMENDED for v1). Same surface they already use to see rejected. New status pill "Änderungen vorgeschlagen" + the note + a CTA "Bearbeiten und erneut einreichen". - (b) A new badge on the entity's detail page (e.g. on the deadline detail page itself).
- (c) Email + push notification.
- (d) All of the above.
Recommend (a) for v1. Email reminder is a natural Phase-2 add-on (it'd reuse the existing reminder-mail plumbing). The entity-detail badge is nice but the user is already seeing the row in /inbox.
Q6 — What action(s) does the requester have on a changes_requested row?
- (a) Edit and resubmit (RECOMMENDED). Primary action. Opens the entity's edit form pre-populated with the original
payload. Saving firesSubmit*→ new pending request withprevious_request_idlinking back. - (b) Withdraw (= dismiss the row from inbox, no DB change). Mostly UI-only — the row is already terminal; "withdraw" would just be a "mark as not-pursuing" toggle.
- (c) Both.
Recommend (a). The row is already terminal once status=changes_requested; the requester either acts on the suggestion (a) or lets the row sit in their inbox history (no action needed). Adding a "dismiss" button is a UI nice-to-have but doesn't change the data model; can defer.
Notifications
Q7 — Who gets notified when "suggest changes" fires?
- (a) Just the requester (RECOMMENDED for v1). Email-reminder path is reused: requester gets a mail "X hat Änderungen vorgeschlagen für …" with the note inline + a link to /inbox.
- (b) Requester + any other potential approvers (they need to know the request is closed, not pending).
- (c) Requester + approval-policy-defined watchers (would require a new
approval_policies.watcherscolumn).
Recommend (a). The request is terminal so other approvers don't need a "this is now your problem" ping — they wouldn't have anything to act on. They see it in /inbox under "Alle sichtbaren" anyway if curious.
Audit
Q8 — Audit row shape on project_events
- (a) New event_type
*_approval_changes_suggestedper entity (RECOMMENDED). Parallel to the existing 4 (requested/approved/rejected/revoked). Two new event types:deadline_approval_changes_suggested,appointment_approval_changes_suggested. Note text goes in metadata. - (b) Bundle with the resubmission — single composite event "approved-with-revisions" when the chain eventually approves.
Recommend (a). Each transition gets its own event row — that's how the existing audit chain already works (one event per state change). It also gives the Verlauf timeline a row to render the approver's note.
3. Implementation sketch (decisions-locked, see §0a)
3.1 Migration 103_approval_suggest_changes.up.sql
-- 1. Extend approval_requests.status CHECK to allow 'changes_requested'.
ALTER TABLE paliad.approval_requests
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
ALTER TABLE paliad.approval_requests
ADD CONSTRAINT approval_requests_status_check
CHECK (status IN ('pending', 'approved', 'rejected', 'revoked', 'superseded', 'changes_requested'));
-- 2. Add counter_payload — the approver's edited values, becomes the
-- `payload` of the NEW pending row spawned in the same tx as the
-- suggest-changes call. Stored on the OLD (now changes_requested) row
-- too so the audit chain can show "approver edited X, Y, Z" without
-- joining to the next row.
ALTER TABLE paliad.approval_requests
ADD COLUMN counter_payload jsonb NULL;
-- 3. Add previous_request_id FK so the new row links back to its origin.
ALTER TABLE paliad.approval_requests
ADD COLUMN previous_request_id uuid NULL
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
CREATE INDEX approval_requests_previous_idx
ON paliad.approval_requests (previous_request_id)
WHERE previous_request_id IS NOT NULL;
.down.sql: drop the index + columns, restore the original CHECK (would reject existing changes_requested rows — that's normal for a breaking-change down).
3.2 Service layer
SuggestChanges is the only new public method on ApprovalService. It runs in one transaction and does five things:
const RequestStatusChangesRequested = "changes_requested"
var ErrSuggestionRequiresChange = errors.New("suggestion_requires_change")
// SuggestChanges closes the pending request as `changes_requested`,
// reverts the entity, then immediately inserts a new pending
// approval_request authored by the caller carrying `counterPayload` as
// its new payload. The new row enters the standard pending flow — anyone
// eligible (including the original requester) can approve, reject,
// suggest-changes-again, etc.
//
// Authorization: caller satisfies canApprove on the OLD row (same gate
// as Approve / Reject). For the NEW row, the caller is the requested_by
// — self-approval is blocked by the standard 3-layer guard. Deadlock
// check (qualified-approver-exists-other-than-caller) runs on the new
// row to avoid spawning an unapprovable request.
//
// counterPayload must differ from the old row's payload OR a non-empty
// note must be present. A no-op suggest (same values, no note) is
// indistinguishable from "I have no opinion" and gets rejected with
// ErrSuggestionRequiresChange.
func (s *ApprovalService) SuggestChanges(
ctx context.Context,
requestID, callerID uuid.UUID,
counterPayload []byte, // jsonb-marshaled
note string,
) (newRequestID *uuid.UUID, err error) {
// 1. Begin tx, lock old row, validate status=pending + canApprove.
// 2. Validate: counterPayload differs from old payload OR note != "".
// 3. Update old row: status='changes_requested', decided_by=callerID,
// decision_note=note, counter_payload=counterPayload.
// 4. applyRevert on the entity (uses old row's pre_image).
// 5. Deadlock-check on the new row's required_role + projectID,
// excluding callerID.
// 6. INSERT new approval_requests row: requested_by=callerID,
// pre_image=<entity-state-as-just-reverted> (= old.pre_image),
// payload=counterPayload, required_role=old.required_role,
// lifecycle_event=old.lifecycle_event, entity_type=old.entity_type,
// entity_id=old.entity_id, status='pending',
// previous_request_id=requestID.
// 7. Re-apply the new payload to the entity (write-then-approve):
// apply the counter_payload's field updates + mark
// approval_status='pending' + pending_request_id=newRequestID.
// 8. Emit *_approval_changes_suggested project_events row
// (metadata: note, counter_payload diff vs original).
// 9. Emit *_approval_requested project_events row for the new
// request (same shape Submit* normally emits).
// 10. Commit.
}
Steps 6 + 7 reuse the existing Submit* plumbing structurally — the cleanest implementation factors out an "insert approval row + apply payload to entity" helper that both Submit* and SuggestChanges call. decide() does not need to know about changes_requested because suggest-changes is not a decision-kernel transition — it's its own end-to-end action.
3.3 HTTP layer
POST /api/approval-requests/{id}/suggest-changes
Body: {
"counter_payload": { ...same shape as Submit*'s payload... },
"note": "free-text explanation, optional iff counter_payload differs from original"
}
Returns: 200 { "new_request_id": "uuid" }
Errors:
400 "suggestion_requires_change" — counter_payload == old payload AND note empty
400 "invalid_counter_payload" — schema validation failure
403 "self_approval_blocked" — caller == old row's requested_by
403 "not_authorized" — caller doesn't satisfy canApprove
404 — request not found / not visible
409 "request_not_pending" — old row already decided
409 "no_qualified_approver" — deadlock on the new row (only caller is eligible)
Register in internal/handlers/handlers.go alongside the existing three:
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
3.4 Frontend
frontend/src/client/views/shape-list.ts — extend the pending-row action group to four buttons:
actions.appendChild(approvalActionBtn("approve", detail));
actions.appendChild(approvalActionBtn("suggest_changes", detail));
actions.appendChild(approvalActionBtn("reject", detail));
actions.appendChild(approvalActionBtn("revoke", detail));
The action union type gains "suggest_changes". Disabled-reason logic is identical to approve/reject (viewer_can_approve gate). i18n: approvals.action.suggest_changes → DE "Änderungen vorschlagen" / EN "Suggest changes".
frontend/src/client/inbox.ts — clicking the suggest-changes button opens a modal, not a window.prompt (the existing reject prompt is OK because reject only needs a note; suggest-changes needs an editable form). The modal:
- Renders the same fields the entity edit form would show, pre-populated from
detail.payload(the requester's proposed values). - Adds a free-text "Vorschlagskommentar" textarea at the bottom (the note).
- On submit: POST
/api/approval-requests/{id}/suggest-changeswith{counter_payload: {...editedFields}, note}. - On success: refresh the bar — the old row flips to
changes_requested, the new row appears aspending.
Where the modal's field-editor lives: a new client/components/approval-edit-modal.ts that takes entity_type + payload + pre_image and returns the edited payload. For v1 it can be a thin wrapper over the existing entity-edit form components (Frist date picker, Termin start/end pickers). Don't build a generic field-editor framework — just deadlines + appointments, hard-coded fields per entity_type.
Status pill for changes_requested — i18n keys + colour:
approvals.status.changes_requested→ DE "Abgelehnt mit Vorschlag" / EN "Declined with changes"- Reuse the existing
approval-pill--historicstyle; no new colour token needed for v1.
The "Edit and resubmit" CTA on the requester's row is NOT needed (m's Q6 reframing) — the requester just sees the new pending row in /inbox, same as any other.
3.5 Inbox filter
The /inbox approval_status filter chip cluster gains changes_requested. The self_requested viewer-role default already includes terminal statuses, so the original requester sees their changes_requested row without changing the default filter.
3.6 Linkage from old row to new row in /inbox
When showing a changes_requested row in /inbox, add a small "→ Neuer Vorschlag von {approver}" link below the note that scrolls / filters to the new pending row (it'll be visible to anyone eligible, including the original requester). The new row has previous_request_id pointing at the old one — so the API response for the old row can hydrate next_request_id (computed: SELECT id FROM approval_requests WHERE previous_request_id = $1 LIMIT 1).
3.7 Email notification (Phase 2 — defer until v1 ships)
The new row triggers the existing *_approval_requested notification path (whatever that is for Submit*) — same audience, same template. No new code. The old row's transition to changes_requested doesn't need its own mail; the new-row mail already tells the audience "X suggested changes to your earlier submission" through the body.
Out of scope for v1: a bespoke "your submission was declined with a counter-proposal" email aimed at the original requester. The new-row mail covers it functionally.
4. Slice plan
Three reviewable slices, each one PR. Combined scope is small/medium.
- Slice A — backend. Migration 103 (CHECK extension +
counter_payload jsonb+previous_request_idFK + index) +SuggestChangesservice method + HTTP handler + service tests (happy path, no-op-suggestion guard, deadlock on new row, self-approval block, request_not_pending). Migration is non-blocking on Postgres; safe for live deploy. - Slice B — frontend. 4th button on /inbox + the edit modal (deadline-fields variant + appointment-fields variant) + status pill
changes_requested("Abgelehnt mit Vorschlag") + i18n keys (DE + EN) + the "→ Neuer Vorschlag" link from old row to new row. End-to-end browser smoke test via Playwright. - Slice C — Verlauf integration. Make sure the
*_approval_changes_suggestedevent renders on the project / deadline / appointment Verlauf timeline alongside the existing 4 approval event types. May or may not need code change depending on how generic the Verlauf row renderer is — likely just an i18n key + an icon mapping.
Don't ship a chain-traversal UI in v1. The previous_request_id FK is captured so the data is there; surfacing the full chain history (n hops back) is a Phase-2 polish.
5. Risks / open considerations
- Chain depth runaway. Nothing stops an "I keep suggesting / they keep counter-suggesting" loop. Same risk as comment threads on GitHub PRs. Out of scope to cap; the social pressure (each round is a 4-Augen action with a name attached) is the natural brake.
- Concurrent suggestions on the same pending row. Two approvers click "suggest changes" at the same time? The existing
getRequestForUpdaterow-lock serialises them; the second caller getsErrRequestNotPending(the first already flipped it). Same guarantee as Approve/Reject today. - Deadlock on the new row. If the suggesting approver is the only qualified approver other than the original requester, the new row's deadlock check returns "no qualified approver" — because the original requester IS now eligible (they're no longer the requested_by), but might not have a high-enough role. The check needs to recognise: caller's pool = "anyone other than the new requester who can canApprove". Original requester counts if they hit the required-role bar. This is just the existing deadlock predicate run against the new (requester, role) tuple; no special-case logic. Surfaced as
409 "no_qualified_approver"to the suggesting approver, with the standard global_admin override path still available. - Counter-payload schema validation. Server must validate
counter_payloadagainst the same schema as a normalSubmit*for that entity_type + lifecycle_event. Otherwise a malicious approver could write garbage values via the suggestion path that wouldn't fly throughSubmit*. Reuse the existing payload-schema validator from the entity services; don't write a parallel. - No-op suggestion guard. Approver clicks suggest-changes but doesn't actually edit anything AND leaves the note empty? Server rejects with
ErrSuggestionRequiresChange. UI guards too (the submit button stays disabled until either the form is dirty OR the note has text). - Migration safety. Non-blocking. Adding a value to a CHECK constraint is a metadata-only change; adding a NULLable column + a NULLable FK is also metadata-only.
- What about a structured per-field suggestion (Q4c)? The
counter_payloadjsonb IS structured — each entity_type has fixed fields. There's no need for a separate "{field, current, suggested}" shape because the diff is computable frompre_image → counter_payloadon the new row. - What about thread-of-notes (Q4b)? Implicit in the chain — each row's
decision_noteis one "note" by one author; followingprevious_request_idbackwards reconstructs the full back-and-forth. A future "thread view" UI is layered on top of this without schema change.
6. m's decisions
See §0a (decisions table) — filled in after the AskUserQuestion phase on 2026-05-19.
7. Out of scope for this design
- Email + push notifications (Phase 2; see §3.7).
- Structured per-field suggestion shape (Phase 2 enhancement).
- Approval-policy
watcherscolumn for notification fan-out. - "Dismiss this row from my inbox" UI toggle (UX-only, not a data-model change).
- Cross-entity suggest-changes (e.g. project, party). Same as the original approval scope — deadlines + appointments only.