Multi-Step Proposal Approval Verification & Application #27

Closed
opened 2026-09-22 17:58:58 +00:00 by yuris · 0 comments
Owner

Summary

Implement the proposal approval verification and execution engine (POST /v1/proposals/{id}/apply, POST /v1/proposals/{id}/reject, and CLI life proposal apply), enforcing cryptographic approval digest verification, preflight live ETag checks, deterministic sequential multi-step execution across CalDAV resources, partial failure isolation without blind rollbacks (exit code 6 / needs_reconciliation), and strict non-replayability of partially applied proposals under new operation keys.

Background & Technical Rationale

CalDAV and WebDAV architectures lack distributed transaction protocols: there is no two-phase commit (2PC) or native atomic batch mutation across multiple calendar resources. When a scheduling proposal comprises multiple steps (such as allocating three work blocks and updating two task deadlines), applying these steps presents severe consistency risks:

  1. Unintended or Stale Execution: An automated agent or user might attempt to execute a proposal that was modified, expired, or never actually reviewed.
  2. Concurrent Drift Between Steps: Between the time a proposal is generated and when it is executed, external clients (e.g., mobile phones, web calendars) may have edited or deleted one of the affected items.
  3. Mid-Flight Step Failures: If Step 1 succeeds on Radicale, but Step 2 fails due to a network drop or ETag conflict, naive systems either crash into undefined states or execute blind rollbacks. Blind rollbacks are dangerous because deleting or restoring earlier resources can overwrite intervening external updates.

Life solves these challenges through a hardened multi-step execution protocol:

  • Cryptographic Approval Contract: Applying a proposal strictly requires an approval payload containing confirmed: true, the exact proposal_digest (SHA-256), and an ISO-8601 reviewed_at timestamp. Mismatches immediately abort with approval_required (HTTP 403, Exit 3) before any remote operations are initiated.
  • Preflight ETag Revalidation: Prior to executing the first remote mutation, the engine performs a preflight pass against Radicale, verifying that every live ETag matches the baseline ETag recorded in the proposal steps. For new items, it asserts non-existence.
  • Ordered Step Commit: Steps execute strictly sequentially. Each step runs under a child operation ID (parent_op:step_id) and its result is durably journaled to SQLite before the subsequent step is dispatched.
  • Partial Failure Isolation (Scenario R09): If any step encounters an error or conflict, execution halts immediately. The failed step is marked failed, subsequent steps remain pending, the parent operation transitions to needs_reconciliation, and the proposal transitions to partial. The CLI exits with code 6 (status: "partial"). No blind rollback is attempted. Repair requires generating and reviewing a fresh proposal.
  • Locking & Replay Protection (Scenario R15): Once any step is applied, the proposal is permanently locked to that parent operation ID. Re-submitting the same proposal under a different operation ID returns proposal_not_applicable (HTTP 409 / Exit 4).

Exact Technical References & Specifications

1. CLI & API Approval & Execution Contract (docs/technical/cli-api.md)

Section: ## Idempotency and approval (lines 65–68):

Creates use conditional remote creation. Existing-item mutations require caller ETag; no hidden read-then-overwrite shortcut. Direct authorized instruction covers the explicit mutation. Proposal apply additionally requires `{approval:{confirmed:true,proposal_digest,reviewed_at}}` supplied only after agent-mediated user review. Review time is audit evidence, not proof a human was present; the authorized client remains responsible. A proposal ID, token scope or confidence alone is insufficient. Server verifies proposal digest, expiry and every current baseline ETag before dispatch. New-resource proposal steps assert nonexistence. Further external races remain protected by conditional writes.

Proposal state is pending, applied, rejected, expired or partial. Once any step is applied, the original proposal cannot be applied again under a fresh key. Same-key replay reports its operation; repair needs a newly reviewed proposal. Operations use the states owned by Timeline.

Section: ## Command-to-route table (lines 49–51):

| `life proposal get ID` | GET /v1/proposals/{id} | read |
| `life proposal apply ID --input -` | POST /v1/proposals/{id}/apply | write |
| `life proposal reject ID --input -` | POST /v1/proposals/{id}/reject | write |

Section: ## Complete fictional examples (lines 163–174):

life proposal apply prop_demo --operation-id op_demo_apply --input - --json, after the user reviewed the fetched digest and all steps, stdin:

{"approval":{"confirmed":true,"proposal_digest":"sha256:8bd4e98ba7204612c8459ad1e38e25c2642c181a234c4fd617a3675f8d720c11","reviewed_at":"2030-04-08T06:10:00Z"}}

Response after all ETags revalidated but a later step conflicts (HTTP 200, exit 6):

{"schema_version":1,"request_id":"req_demo_apply","status":"partial","data":{"operation_id":"op_demo_apply","state":"needs_reconciliation","steps":[{"step_id":"step_a","status":"applied","etag":"\"block-v2\""},{"step_id":"step_b","status":"failed","code":"etag_conflict"},{"step_id":"step_c","status":"pending"}]},"warnings":[],"error":{"code":"partial_apply","message":"One step applied; repair requires a newly reviewed proposal","retryable":false,"operation_id":"op_demo_apply"}}

Section: ## Error identifiers (lines 111–125):

| Code | Meaning / handling |
|---|---|
| approval_required | Obtain explicit review; exit 3 |
| etag_conflict | Baseline changed; reread and obtain renewed decision |
| proposal_expired, proposal_not_applicable | Generate and review a new proposal |
| partial_apply, context_pending | Some calendar steps or context delivery remain incomplete |

2. Timeline Consistency & Multi-Item Proposals (docs/technical/timeline-consistency.md)

Section: ## Multi-item proposals (lines 148–151):

No cross-resource atomicity. Preflight all steps, revalidate every ETag and creation precondition, then apply in immutable deterministic step order. Child operations derive stable identities from parent operation and step ID. Commit each step result before dispatching the next. Stop on first conflict, error or unknown outcome. Return every step as applied/pending/failed/unknown; partial parent is needs_reconciliation and proposal is partial. Do not roll back over subsequent external edits. Same-key apply replays progress without reapplying uncertain steps; repair is a newly generated/reviewed proposal.

Section: ## Operation state machine (lines 121–128):

- prepared: intent durably committed, outcome not durably established. After restart it is potentially dispatched, not automatically safe to replay.
- applied: intended remote result confirmed and journaled, or a local-only operation committed. Confirmed no-op carries changed:false, not a fake mutation event.
- failed: evidence proves intended write was not applied, e.g. validation failure or conditional conflict. Authentication/server errors count as failed only where response semantics establish no write.
- unknown: timeout/disconnection or journal failure leaves remote outcome uncertain.
- needs_reconciliation: recovery could not establish enough evidence, or a multi-step operation needs reviewed repair. It is not permission to retry blindly.

Section: ## Database tables for operations and proposals (lines 15–75):

CREATE TABLE operations (
  operation_id TEXT PRIMARY KEY,
  request_hash TEXT NOT NULL,
  schema_version INTEGER NOT NULL,
  state TEXT NOT NULL CHECK(state IN
    ('prepared','applied','failed','unknown','needs_reconciliation')),
  actor TEXT NOT NULL,
  prepared_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  intent_json TEXT NOT NULL,
  baseline_json TEXT,
  result_json TEXT,
  parent_operation_id TEXT REFERENCES operations(operation_id),
  step_id TEXT,
  UNIQUE(parent_operation_id, step_id)
);

CREATE TABLE proposals (
  proposal_id TEXT PRIMARY KEY,
  schema_version INTEGER NOT NULL,
  state TEXT NOT NULL CHECK(state IN
    ('pending','applied','rejected','expired','partial')),
  digest TEXT NOT NULL,
  created_at TEXT NOT NULL,
  expires_at TEXT NOT NULL,
  steps_json TEXT NOT NULL,
  policy_json TEXT NOT NULL,
  operation_id TEXT REFERENCES operations(operation_id),
  approval_json TEXT
);

3. Verification Scenarios R09, R14, R15 (docs/technical/verification.md)

Section: ## Recovery scenarios (lines 95, 100–101):

| ID | Boundary / injected failure | Observable acceptance |
|---|---|---|
| R09 | Three-step proposal; second step conflicts after preflight | First applied, second failed, third pending; exit 6; new reviewed repair, no blind rollback |
| R14 | Proposal ID supplied without reviewed digest, stale digest or expired proposal | approval_required/invalid_input/proposal_expired as appropriate; zero calendar writes |
| R15 | Same proposal applied under new operation ID after partial success | proposal_not_applicable; no replay of already-applied/uncertain steps |

What to build

1. Proposal Validator & Idempotency Check (src/domain/proposal/validator.rs)

  • First check operations table by Idempotency-Key / operation_id:
    • If existing operation found with matching request fingerprint: return the recorded outcome or current partial step breakdown without re-executing steps or re-dispatching uncertain operations (docs/technical/cli-api.md:63, 67).
    • If existing operation found with different fingerprint: return HTTP 409 idempotency_conflict (Exit 4).
  • Fetch proposal from SQLite by proposal_id.
    • If not found, return 404 Not Found with code not_found.
    • If current state is expired or now() > expires_at, return 400 Bad Request with code proposal_expired (Exit 2).
    • If current state is applied, return 400 Bad Request with code proposal_not_applicable.
    • If current state is rejected, return 400 Bad Request with code proposal_not_applicable.
    • If current state is partial:
      • Once any step is applied, the proposal cannot be applied again under a fresh operation key; return HTTP 409 proposal_not_applicable (Scenario R15). Any repair requires a newly reviewed proposal (docs/technical/cli-api.md:67). Same-key replay reports the existing operation status without re-executing steps.
  • Validate approval payload:
    • Input schema:
      {
        "approval": {
          "confirmed": true,
          "proposal_digest": "sha256:8bd4e98ba...",
          "reviewed_at": "2026-09-23T10:00:00Z"
        }
      }
      
    • If approval is missing or confirmed != true: return 403 Forbidden with code approval_required (Exit 3) (Scenario R14).
    • If proposal_digest != proposal.digest: return 403 Forbidden with code approval_required (Exit 3) (Scenario R14).
    • If reviewed_at is missing or invalid: return 400 Bad Request with code invalid_input.

2. Live Preflight ETag Verifier (src/domain/proposal/preflight.rs)

  • Deserialize steps_json into ordered step structs.
  • For each step:
    • If action == "create_event":
      • Dispatch CalDAV HEAD/GET for the target URI to assert non-existence.
      • If resource already exists, fail preflight with etag_conflict before executing any writes.
    • If action == "update_event", action == "cancel_event", or action == "update_task":
      • Dispatch CalDAV HEAD/GET for item_id.
      • Fetch live remote ETag.
      • Compare live ETag to step.baseline_etag.
      • If mismatch, fail preflight with code etag_conflict (HTTP 412, Exit 4).
  • Ensure that if any step fails preflight, ZERO remote writes are issued across all steps.

3. Step-by-Step Proposal Executor (src/domain/proposal/executor.rs)

  • Register parent operation in operations table with state: 'prepared'.
  • Link proposal to operation: update proposals SET operation_id = ?, approval_json = ? WHERE proposal_id = ?.
  • Iterate through steps in strict sequential order:
    • For step i:
      • Check child operation (parent_operation_id, step_id). If already applied, preserve existing result and proceed to step i+1.
      • Commit child operation with state: 'prepared'.
      • Execute remote CalDAV write with conditional headers (If-Match: baseline_etag or If-None-Match: *).
      • On success:
        • Record new ETag from response.
        • Update child operation to state: 'applied', record result in operations.
        • Append confirmed service fact into life_events with evidence_class: "confirmed_service".
      • On failure (e.g. HTTP 412 conflict, HTTP 500, or network disconnect):
        • Update child operation to state: 'failed' (or 'unknown' if network dropped mid-transfer).
        • Mark all subsequent steps i+1 \dots N as pending.
        • Update parent operation in operations to state: 'needs_reconciliation'.
        • Update proposal in proposals to state: 'partial'.
        • Terminate execution immediately. Do NOT roll back step 1.
        • Return partial execution envelope (Scenario R09):
          {
            "schema_version": 1,
            "request_id": "req_...",
            "status": "partial",
            "data": {
              "operation_id": "op_...",
              "state": "needs_reconciliation",
              "steps": [
                {"step_id": "step_1", "status": "applied", "etag": "\"block-v2\""},
                {"step_id": "step_2", "status": "failed", "code": "etag_conflict"},
                {"step_id": "step_3", "status": "pending"}
              ]
            },
            "warnings": [],
            "error": {
              "code": "partial_apply",
              "message": "One step applied; repair requires a newly reviewed proposal",
              "retryable": false,
              "operation_id": "op_..."
            }
          }
          
  • If all steps succeed:
    • Update parent operation to state: 'applied'.
    • Update proposal to state: 'applied'.
    • Return HTTP 200 OK with status: "ok" and list of applied steps with new ETags.

4. Proposal Rejection Handler (src/api/proposal_reject.rs)

  • Implement POST /v1/proposals/{id}/reject:
    • Validate proposal exists and is in state: 'pending'.
    • Update proposal state = 'rejected', store optional rejection reason in proposals.
    • Perform ZERO writes to Radicale.

5. CLI Presentation & Exit Code Mapping (src/cli/proposal.rs)

  • Implement life proposal apply <ID> --input - [--operation-id KEY] [--json]:
    • Map API responses to strict CLI exit codes:
      • Complete success -> Exit 0.
      • Missing/mismatched approval -> Exit 3 (approval_required).
      • Preflight conflict -> Exit 4 (etag_conflict).
      • Partial application -> Exit 6 (status: "partial", partial_apply).
      • Proposal not applicable / re-application attempt -> Exit 4 (proposal_not_applicable).

Acceptance Criteria

  • Submitting life proposal apply without matching proposal_digest fails with approval_required (HTTP 403, Exit 3) with zero remote writes (Scenario R14).
  • Submitting an expired proposal fails with proposal_expired (Exit 2).
  • Preflight verifies all live ETags; a mismatch on any step aborts before any remote write is sent (verifying Scenario R14).
  • Simulating a failure on Step 2 of a 3-step proposal leaves Step 1 applied with new ETag, Step 2 marked failed, Step 3 pending, marks proposal state: "partial", parent operation needs_reconciliation, and exits with code 6 without attempting rollback (verifying Scenario R09).
  • Attempting to apply an already partially applied proposal under a new operation ID is rejected with proposal_not_applicable (verifying Scenario R15).
  • Same-key replay of an operation returns the existing step breakdown without re-executing steps or re-dispatching uncertain operations (docs/technical/cli-api.md:63, 67).
  • Preflight live ETag checks cover all existing-resource steps including update_event, cancel_event, and update_task, refusing all writes if any baseline changed.
  • Calling life proposal reject marks the proposal rejected in SQLite and performs zero writes to Radicale.

Notes

  • Refusal order validates evidence and requires the durable journal before any step intent; every refusal path asserts no request of any kind plus unchanged journal counts.
  • Preflight revalidates every step (including create nonexistence) before the parent intent is committed; the dispatched If-Match is asserted equal to the reviewed baseline.
  • A step conflict leaves [applied, failed, pending, ...] with the proposal partial, parent needs_reconciliation, zero DELETEs, no rollback and no retry.
  • Reject constructs no CalDAV client at all; a pending proposal past its window reports as expired.
  • No dependency added.

Blocked by

  • #026: Interruption Fact Logging & Schedule Replanning
## Summary Implement the proposal approval verification and execution engine (`POST /v1/proposals/{id}/apply`, `POST /v1/proposals/{id}/reject`, and CLI `life proposal apply`), enforcing cryptographic approval digest verification, preflight live ETag checks, deterministic sequential multi-step execution across CalDAV resources, partial failure isolation without blind rollbacks (exit code 6 / `needs_reconciliation`), and strict non-replayability of partially applied proposals under new operation keys. ## Background & Technical Rationale CalDAV and WebDAV architectures lack distributed transaction protocols: there is no two-phase commit (2PC) or native atomic batch mutation across multiple calendar resources. When a scheduling proposal comprises multiple steps (such as allocating three work blocks and updating two task deadlines), applying these steps presents severe consistency risks: 1. **Unintended or Stale Execution:** An automated agent or user might attempt to execute a proposal that was modified, expired, or never actually reviewed. 2. **Concurrent Drift Between Steps:** Between the time a proposal is generated and when it is executed, external clients (e.g., mobile phones, web calendars) may have edited or deleted one of the affected items. 3. **Mid-Flight Step Failures:** If Step 1 succeeds on Radicale, but Step 2 fails due to a network drop or ETag conflict, naive systems either crash into undefined states or execute blind rollbacks. Blind rollbacks are dangerous because deleting or restoring earlier resources can overwrite intervening external updates. Life solves these challenges through a hardened multi-step execution protocol: - **Cryptographic Approval Contract:** Applying a proposal strictly requires an approval payload containing `confirmed: true`, the exact `proposal_digest` (SHA-256), and an ISO-8601 `reviewed_at` timestamp. Mismatches immediately abort with `approval_required` (HTTP 403, Exit 3) before any remote operations are initiated. - **Preflight ETag Revalidation:** Prior to executing the first remote mutation, the engine performs a preflight pass against Radicale, verifying that every live ETag matches the baseline ETag recorded in the proposal steps. For new items, it asserts non-existence. - **Ordered Step Commit:** Steps execute strictly sequentially. Each step runs under a child operation ID (`parent_op:step_id`) and its result is durably journaled to SQLite before the subsequent step is dispatched. - **Partial Failure Isolation (Scenario R09):** If any step encounters an error or conflict, execution halts immediately. The failed step is marked `failed`, subsequent steps remain `pending`, the parent operation transitions to `needs_reconciliation`, and the proposal transitions to `partial`. The CLI exits with code 6 (`status: "partial"`). No blind rollback is attempted. Repair requires generating and reviewing a fresh proposal. - **Locking & Replay Protection (Scenario R15):** Once any step is applied, the proposal is permanently locked to that parent operation ID. Re-submitting the same proposal under a different operation ID returns `proposal_not_applicable` (HTTP 409 / Exit 4). ## Exact Technical References & Specifications ### 1. CLI & API Approval & Execution Contract (`docs/technical/cli-api.md`) Section: `## Idempotency and approval` (lines 65–68): ```markdown Creates use conditional remote creation. Existing-item mutations require caller ETag; no hidden read-then-overwrite shortcut. Direct authorized instruction covers the explicit mutation. Proposal apply additionally requires `{approval:{confirmed:true,proposal_digest,reviewed_at}}` supplied only after agent-mediated user review. Review time is audit evidence, not proof a human was present; the authorized client remains responsible. A proposal ID, token scope or confidence alone is insufficient. Server verifies proposal digest, expiry and every current baseline ETag before dispatch. New-resource proposal steps assert nonexistence. Further external races remain protected by conditional writes. Proposal state is pending, applied, rejected, expired or partial. Once any step is applied, the original proposal cannot be applied again under a fresh key. Same-key replay reports its operation; repair needs a newly reviewed proposal. Operations use the states owned by Timeline. ``` Section: `## Command-to-route table` (lines 49–51): ```markdown | `life proposal get ID` | GET /v1/proposals/{id} | read | | `life proposal apply ID --input -` | POST /v1/proposals/{id}/apply | write | | `life proposal reject ID --input -` | POST /v1/proposals/{id}/reject | write | ``` Section: `## Complete fictional examples` (lines 163–174): ```markdown life proposal apply prop_demo --operation-id op_demo_apply --input - --json, after the user reviewed the fetched digest and all steps, stdin: {"approval":{"confirmed":true,"proposal_digest":"sha256:8bd4e98ba7204612c8459ad1e38e25c2642c181a234c4fd617a3675f8d720c11","reviewed_at":"2030-04-08T06:10:00Z"}} Response after all ETags revalidated but a later step conflicts (HTTP 200, exit 6): {"schema_version":1,"request_id":"req_demo_apply","status":"partial","data":{"operation_id":"op_demo_apply","state":"needs_reconciliation","steps":[{"step_id":"step_a","status":"applied","etag":"\"block-v2\""},{"step_id":"step_b","status":"failed","code":"etag_conflict"},{"step_id":"step_c","status":"pending"}]},"warnings":[],"error":{"code":"partial_apply","message":"One step applied; repair requires a newly reviewed proposal","retryable":false,"operation_id":"op_demo_apply"}} ``` Section: `## Error identifiers` (lines 111–125): ```markdown | Code | Meaning / handling | |---|---| | approval_required | Obtain explicit review; exit 3 | | etag_conflict | Baseline changed; reread and obtain renewed decision | | proposal_expired, proposal_not_applicable | Generate and review a new proposal | | partial_apply, context_pending | Some calendar steps or context delivery remain incomplete | ``` ### 2. Timeline Consistency & Multi-Item Proposals (`docs/technical/timeline-consistency.md`) Section: `## Multi-item proposals` (lines 148–151): ```markdown No cross-resource atomicity. Preflight all steps, revalidate every ETag and creation precondition, then apply in immutable deterministic step order. Child operations derive stable identities from parent operation and step ID. Commit each step result before dispatching the next. Stop on first conflict, error or unknown outcome. Return every step as applied/pending/failed/unknown; partial parent is needs_reconciliation and proposal is partial. Do not roll back over subsequent external edits. Same-key apply replays progress without reapplying uncertain steps; repair is a newly generated/reviewed proposal. ``` Section: `## Operation state machine` (lines 121–128): ```markdown - prepared: intent durably committed, outcome not durably established. After restart it is potentially dispatched, not automatically safe to replay. - applied: intended remote result confirmed and journaled, or a local-only operation committed. Confirmed no-op carries changed:false, not a fake mutation event. - failed: evidence proves intended write was not applied, e.g. validation failure or conditional conflict. Authentication/server errors count as failed only where response semantics establish no write. - unknown: timeout/disconnection or journal failure leaves remote outcome uncertain. - needs_reconciliation: recovery could not establish enough evidence, or a multi-step operation needs reviewed repair. It is not permission to retry blindly. ``` Section: `## Database tables for operations and proposals` (lines 15–75): ```sql CREATE TABLE operations ( operation_id TEXT PRIMARY KEY, request_hash TEXT NOT NULL, schema_version INTEGER NOT NULL, state TEXT NOT NULL CHECK(state IN ('prepared','applied','failed','unknown','needs_reconciliation')), actor TEXT NOT NULL, prepared_at TEXT NOT NULL, updated_at TEXT NOT NULL, intent_json TEXT NOT NULL, baseline_json TEXT, result_json TEXT, parent_operation_id TEXT REFERENCES operations(operation_id), step_id TEXT, UNIQUE(parent_operation_id, step_id) ); CREATE TABLE proposals ( proposal_id TEXT PRIMARY KEY, schema_version INTEGER NOT NULL, state TEXT NOT NULL CHECK(state IN ('pending','applied','rejected','expired','partial')), digest TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, steps_json TEXT NOT NULL, policy_json TEXT NOT NULL, operation_id TEXT REFERENCES operations(operation_id), approval_json TEXT ); ``` ### 3. Verification Scenarios R09, R14, R15 (`docs/technical/verification.md`) Section: `## Recovery scenarios` (lines 95, 100–101): ```markdown | ID | Boundary / injected failure | Observable acceptance | |---|---|---| | R09 | Three-step proposal; second step conflicts after preflight | First applied, second failed, third pending; exit 6; new reviewed repair, no blind rollback | | R14 | Proposal ID supplied without reviewed digest, stale digest or expired proposal | approval_required/invalid_input/proposal_expired as appropriate; zero calendar writes | | R15 | Same proposal applied under new operation ID after partial success | proposal_not_applicable; no replay of already-applied/uncertain steps | ``` ## What to build ### 1. Proposal Validator & Idempotency Check (`src/domain/proposal/validator.rs`) - First check `operations` table by `Idempotency-Key` / `operation_id`: - If existing operation found with matching request fingerprint: return the recorded outcome or current partial step breakdown without re-executing steps or re-dispatching uncertain operations (`docs/technical/cli-api.md:63, 67`). - If existing operation found with different fingerprint: return HTTP 409 `idempotency_conflict` (Exit 4). - Fetch proposal from SQLite by `proposal_id`. - If not found, return `404 Not Found` with code `not_found`. - If current state is `expired` or `now() > expires_at`, return `400 Bad Request` with code `proposal_expired` (Exit 2). - If current state is `applied`, return `400 Bad Request` with code `proposal_not_applicable`. - If current state is `rejected`, return `400 Bad Request` with code `proposal_not_applicable`. - If current state is `partial`: - Once any step is applied, the proposal cannot be applied again under a fresh operation key; return HTTP 409 `proposal_not_applicable` (Scenario R15). Any repair requires a newly reviewed proposal (`docs/technical/cli-api.md:67`). Same-key replay reports the existing operation status without re-executing steps. - Validate approval payload: - Input schema: ```json { "approval": { "confirmed": true, "proposal_digest": "sha256:8bd4e98ba...", "reviewed_at": "2026-09-23T10:00:00Z" } } ``` - If `approval` is missing or `confirmed != true`: return `403 Forbidden` with code `approval_required` (Exit 3) (Scenario R14). - If `proposal_digest != proposal.digest`: return `403 Forbidden` with code `approval_required` (Exit 3) (Scenario R14). - If `reviewed_at` is missing or invalid: return `400 Bad Request` with code `invalid_input`. ### 2. Live Preflight ETag Verifier (`src/domain/proposal/preflight.rs`) - Deserialize `steps_json` into ordered step structs. - For each step: - If `action == "create_event"`: - Dispatch CalDAV HEAD/GET for the target URI to assert non-existence. - If resource already exists, fail preflight with `etag_conflict` before executing any writes. - If `action == "update_event"`, `action == "cancel_event"`, or `action == "update_task"`: - Dispatch CalDAV HEAD/GET for `item_id`. - Fetch live remote `ETag`. - Compare live `ETag` to `step.baseline_etag`. - If mismatch, fail preflight with code `etag_conflict` (HTTP 412, Exit 4). - Ensure that if any step fails preflight, ZERO remote writes are issued across all steps. ### 3. Step-by-Step Proposal Executor (`src/domain/proposal/executor.rs`) - Register parent operation in `operations` table with `state: 'prepared'`. - Link proposal to operation: update `proposals SET operation_id = ?, approval_json = ? WHERE proposal_id = ?`. - Iterate through steps in strict sequential order: - For step $i$: - Check child operation `(parent_operation_id, step_id)`. If already `applied`, preserve existing result and proceed to step $i+1$. - Commit child operation with `state: 'prepared'`. - Execute remote CalDAV write with conditional headers (`If-Match: baseline_etag` or `If-None-Match: *`). - On success: - Record new ETag from response. - Update child operation to `state: 'applied'`, record result in `operations`. - Append confirmed service fact into `life_events` with `evidence_class: "confirmed_service"`. - On failure (e.g. HTTP 412 conflict, HTTP 500, or network disconnect): - Update child operation to `state: 'failed'` (or `'unknown'` if network dropped mid-transfer). - Mark all subsequent steps $i+1 \dots N$ as `pending`. - Update parent operation in `operations` to `state: 'needs_reconciliation'`. - Update proposal in `proposals` to `state: 'partial'`. - Terminate execution immediately. Do NOT roll back step 1. - Return partial execution envelope (Scenario R09): ```json { "schema_version": 1, "request_id": "req_...", "status": "partial", "data": { "operation_id": "op_...", "state": "needs_reconciliation", "steps": [ {"step_id": "step_1", "status": "applied", "etag": "\"block-v2\""}, {"step_id": "step_2", "status": "failed", "code": "etag_conflict"}, {"step_id": "step_3", "status": "pending"} ] }, "warnings": [], "error": { "code": "partial_apply", "message": "One step applied; repair requires a newly reviewed proposal", "retryable": false, "operation_id": "op_..." } } ``` - If all steps succeed: - Update parent operation to `state: 'applied'`. - Update proposal to `state: 'applied'`. - Return HTTP 200 OK with `status: "ok"` and list of applied steps with new ETags. ### 4. Proposal Rejection Handler (`src/api/proposal_reject.rs`) - Implement `POST /v1/proposals/{id}/reject`: - Validate proposal exists and is in `state: 'pending'`. - Update proposal `state = 'rejected'`, store optional rejection reason in `proposals`. - Perform ZERO writes to Radicale. ### 5. CLI Presentation & Exit Code Mapping (`src/cli/proposal.rs`) - Implement `life proposal apply <ID> --input - [--operation-id KEY] [--json]`: - Map API responses to strict CLI exit codes: - Complete success -> Exit 0. - Missing/mismatched approval -> Exit 3 (`approval_required`). - Preflight conflict -> Exit 4 (`etag_conflict`). - Partial application -> Exit 6 (`status: "partial"`, `partial_apply`). - Proposal not applicable / re-application attempt -> Exit 4 (`proposal_not_applicable`). ## Acceptance Criteria - [x] Submitting `life proposal apply` without matching `proposal_digest` fails with `approval_required` (HTTP 403, Exit 3) with zero remote writes (Scenario R14). - [x] Submitting an expired proposal fails with `proposal_expired` (Exit 2). - [x] Preflight verifies all live ETags; a mismatch on any step aborts before any remote write is sent (verifying Scenario R14). - [x] Simulating a failure on Step 2 of a 3-step proposal leaves Step 1 applied with new ETag, Step 2 marked failed, Step 3 pending, marks proposal `state: "partial"`, parent operation `needs_reconciliation`, and exits with code 6 without attempting rollback (verifying Scenario R09). - [x] Attempting to apply an already partially applied proposal under a new operation ID is rejected with `proposal_not_applicable` (verifying Scenario R15). - [x] Same-key replay of an operation returns the existing step breakdown without re-executing steps or re-dispatching uncertain operations (`docs/technical/cli-api.md:63, 67`). - [x] Preflight live ETag checks cover all existing-resource steps including update_event, cancel_event, and update_task, refusing all writes if any baseline changed. - [x] Calling `life proposal reject` marks the proposal rejected in SQLite and performs zero writes to Radicale. ## Notes - Refusal order validates evidence and requires the durable journal before any step intent; every refusal path asserts no request of any kind plus unchanged journal counts. - Preflight revalidates every step (including create nonexistence) before the parent intent is committed; the dispatched `If-Match` is asserted equal to the reviewed baseline. - A step conflict leaves `[applied, failed, pending, ...]` with the proposal `partial`, parent `needs_reconciliation`, zero `DELETE`s, no rollback and no retry. - Reject constructs no CalDAV client at all; a `pending` proposal past its window reports as expired. - No dependency added. ## Blocked by - #026: Interruption Fact Logging & Schedule Replanning
yuris changed title from Issue #027: Multi-Step Proposal Approval Verification & Application to Multi-Step Proposal Approval Verification & Application 2026-09-22 20:02:35 +00:00
yuris closed this issue 2026-09-23 03:01:03 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/life-service#27
No description provided.