feat(api): proposal approval verification and ordered application #70
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-27-proposal"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
Issue #25 stores a reviewable proposal and #26 replans around a reported interruption, but nothing could act on a proposal. This lands the missing half of the approval contract in
docs/technical/cli-api.md#idempotency-and-approval: a proposal is applied only against the review that was actually performed, every baseline is revalidated live before anything is dispatched, the steps execute in their immutable order each as its own child operation, and a step that does not land leaves an explicitly partial proposal instead of a blind rollback or an automatic retry.Scenarios R09 (a step conflicts after preflight), R14 (an unreviewed, stale or expired proposal) and R15 (a re-application after partial success) are the acceptance boundary.
Changes
src/api/proposals.rs(new) — the three routes and the whole workflow:GET /v1/proposals/{id}: the stored proposal with its state, digest, lifetime, ordered steps (target, intended change, baseline version, reason) and the recorded policy exceptions. Apendingproposal whose review window has closed readsexpired; nothing is written by the read.POST /v1/proposals/{id}/apply: the approval ({approval:{confirmed:true,proposal_digest,reviewed_at}}) is verified before anything is read; the digest, expiry and stored state decide applicability; then every step's baseline ETag and every creation precondition are revalidated live, and the first mismatch refuses the whole apply naming that step with nothing dispatched. Only then does the parent operation commit its intent, and the steps run in order.POST /v1/proposals/{id}/reject: one local transaction marking the proposalrejectedwith the optional reason. No CalDAV client is constructed on this path.src/api/update.rs/src/api/creation.rs— a step is dispatched through exactly the paths a direct mutation uses (update_task_as_step,create_event_as_step), with the step's own operation identity and the reviewed baseline as the conditional precondition. The step link travels with the call, so no parallel writer exists.src/history/journal.rs—StepLinkplus astepargument onprepare_operation, written with the prepared intent (so a crash cannot leave a dispatched step unlinked);step_operationsreads one parent's children.src/history/proposals.rs—commit_apply_outcome(the parent operation's state and the proposal's state in one transaction, so the durable record can never disagree about whether the proposal was applied) andrecord_rejection.src/history/recovery.rs— a multi-step parent is reconciled from its recorded children rather than from a live readback of a single target it never had.src/api/mod.rs—approval_required(403/3),proposal_expired(400/2) andproposal_not_applicable(409/4) failure shapes, three handlers and three routes (auth::route_accessalready mapped them, verified).src/cli.rs/src/client.rs—life proposal get|apply|reject, with the daemon's own approval rule applied before transmission and the documented exit codes (3 unreviewed/mismatched, 2 expired, 4 not applicable, 6 partial).src/domain/proposal.rs—parse_stepsandstep_operation_id(the child identity rule:parent:step).Behaviour decisions worth naming:
failed, the remaining steps arepending, the parent isneeds_reconciliation, the proposal ispartialand the answer ispartial_apply/exit 6. Nothing is rolled back over whatever the calendar holds now, and nothing is retried. The proposal is markedpartialeven when no step was confirmed applied — the safe reading of "repair needs a newly reviewed proposal" once a dispatch has been attempted.idempotency_conflict. A replay of an apply whose outcome was never recorded (crash between a step's dispatch and the parent's commit) reports the progress its child operations prove and dispatches nothing.unknown/needs_reconciliation) is reported asunknownwith its operation ID, never as applied.reviewed_atand the verified digest are recorded as audit evidence. Review time is not proof a human was present: the module doc, theapproval_requiredmessage and the CLI footer all state that the authorized client remains responsible for the review it supplied.Deviations from the issue text, all deliberate:
proposal_not_applicableis HTTP 409 / exit 4 for every non-applicable state (applied, partial, rejected). The issue's prose says 400 for applied/rejected, but its own CLI mapping requires exit 4, anddocs/technical/cli-api.mdmaps exit 4 to 409/412; one code cannot carry two mappings.proposal_expiredis HTTP 400 / exit 2, per the issue's acceptance criterion and the exit table (exit 3 is the approval/clarification class, which an expired proposal cannot resolve).operation_idcolumn keeps the operation that generated it; the operation that decided it (apply or rejection) is recorded inapproval_jsonbeside the decision, so generation provenance is never overwritten.state: applied,steps: [], zero writes) rather than being refused, per the operation state machine's no-op rule.prepare_operationgained astep: Option<&StepLink>argument; the existing callers passNone(the link is written with the intent rather than patched in afterwards).Verification
cargo test— 654 tests pass, 0 failures (the suite was 633 before; the 21 new tests are below).cargo clippy --all-targets -- -D warningsandcargo fmt --checkare clean. No dependency was added or changed.tests/proposal_apply_tests.rs(new, 21 tests) observes everything through the real path — the proposal is generated by the daemon's planner against a mock CalDAV server over TCP, applied through the daemon's own router, and read back from SQLite:reviewed_at, a stale digest and an expired proposal are each refused with their documented code and status, zero requests of any kind reach the backend, and the journal row counts are unchanged.etag_conflictnaming the step, with only reads issued and zeroPUTs.412, the four-step proposal reports["applied", "failed", "pending", "pending"], HTTP 200 / statuspartial/partial_applywith the operation ID, the first step's write still on the backend and its ETag reported, exactly twoPUTs and zeroDELETEs, the proposalpartial, the parentneeds_reconciliation, and both child operations linked byparent_operation_id+step_id. The dispatchedIf-Matchis asserted to be the reviewed baseline, and the creation'sIf-None-Match: *.proposal_not_applicablewith no request issued; the same key and payload replays the recorded partial outcome with no secondPUT; the same key with a changed request isidempotency_conflict.500from the backend leaves the stepunknownwithoperation_unknownand its operation ID (its own operation recordedneeds_reconciliation), the proposalpartial, and the remaining steps pending.partial/operation_unknownreporting that step, dispatching nothing.rejectedwith its reason, touches no calendar resource, replays under the same key, and refuses a second rejection; an expired proposal cannot be rejected.proposal getrenders the digest and steps,proposal applyexits 3 for an unreviewed payload without contacting the daemon, exits 6 with the per-step breakdown for a partial apply, exits 4 for a re-application, andproposal rejectrecords the decision.Related Issues
Closes #27
Coordinator review — APPROVED
Verified
e83638dmyself:cargo test654 passed / 0 failed (21 new proposal-apply tests),cargo clippy --all-targets -- -D warnings0 errors,cargo fmt --checkclean.What I checked beyond the summary:
run_proposal_applyvalidates the operation key, body and approval evidence, then requires the durable journal before anything else ("without it no step intent could be committed, so no step may be dispatched"), then loads the proposal, computes reviewability (expiry/state/digest), preflights, and only then commits the parent intent and dispatches steps. Every refusal path testsrequest_count() == requests_beforeand unchanged journal counts, so "zero writes" means no request of any kind, not just noPUT.If-Matchis asserted to equal the reviewed baseline, which is what makes the digest meaningful rather than decorative.[applied, failed, pending, pending]with the proposalpartial, the parentneeds_reconciliation, children linked by(parent, step_id), exactly twoPUTs and zeroDELETEs, and the applied step's write still on the backend. No rollback, no retry — the failure is recorded, not undone.pendingrow past its window is reported as expired (a computed view rather than a stored lie), so the state cannot be stale in the one place that matters.proposal_not_applicable.reviewed_at+ verified digest recorded), consistent with the contract's position that the authorized client, not the service, carries responsibility for review.Merging.
#28(outcomes) and#31(agent skill) are now unblocked.feat(api): proposal approval verification and ordered application (#27)to feat(api): proposal approval verification and ordered application