Add opt-in conflict checks before body replacements

RO
6d ago
+696-10

What

Adds optional --if-unmodified-since <updated_at> and explicit --force conflict override to the supported typed replacement commands. The caller must retain the timestamp from the representation used to prepare the edit. A server-revalidated GET requires equal parsed timestamp instants before uploads or writes; failed reads and missing, zero or invalid timestamps refuse even with force.

Coverage: comments, messages, to-dos, cards, to-do lists, schedule entries, check-in answers, chat lines, documents/uploads/vaults, file replacement, Doors, projects, templates, notes, gauges and timesheet entries. See exact coverage and limitations.

Boost editing is explicitly excluded and unchanged. Its supported API/SDK representation has no updated_at baseline. Creation time, a parent timestamp and ETags are not substituted.

Limits

  • Opt-in only: no baseline, including first use, remains unprotected. A caller-supplied timestamp is not a remembered read baseline.
  • Raw API writes, MCP/TUI/SDK callers, creation commands and other update paths are not globally guarded.
  • GET then PUT is not atomic: an intervening edit after the check can still be lost. Whole-record comparison can conservatively refuse unrelated field edits.
  • No local authored-body ledger, append mode, server conditional writes or automatic baseline store is implemented.

Verification

  • Full local mise exec golangci-lint@2.15.0 ruby@3.4.12 -- bin/ci, including Go regression and BATS integration suites. Zizmor ran in its default offline mode; platform/credential helper skips are not live-platform coverage.
  • Focused guard regressions include stale/future/fractional baselines, equivalent offsets, missing/malformed live versions, failed GETs, force prerequisites, explicit clears and attachment/file-only updates refusing before side effects. Boost exclusion has a regression test.
  • Real isolated development runtime, using synthetic fixtures and the supported flat Doors JSON API: comments stale refusal with exact unchanged readback, matching success, override, no-baseline and first-use behavior; Doors stale refusal, matching update/clear success and override; file replacement stale description/clear/file-only refusal and matching clear; malformed/missing-target prerequisites; raw API remaining unguarded.
  • The added comments smoke test ran unchanged in an isolated retained harness and passed. Source review found no actionable defects; it is separate from runtime verification.

Safe reproduction

On a disposable record you own, read and keep its updated_at, make an intervening edit, then try a replacement with the older timestamp:

basecamp comments show <synthetic-comment-id> --json
basecamp comments update <synthetic-comment-id> 'replacement' \
  --if-unmodified-since '<saved-updated_at>' --json

The command must refuse with a conflict and leave the record unchanged. Re-fetch, reconcile and use the timestamp belonging to the reconciled edit for a matching update. Only use --force deliberately to overwrite a mismatch; it does not waive prerequisites.

No production/beta validation, merge, release or deployment is claimed.


Summary by cubic

Adds an opt-in conflict guard to typed replacement commands so callers can refuse to overwrite records edited since their last read. When --if-unmodified-since <updated_at> is set, the CLI runs a server-revalidated GET and requires the live updated_at to parse to the same instant before writing; --force overrides a version mismatch, while failed reads and missing, zero, or invalid timestamps refuse even with force.

The guard covers comments, messages, todos and to-do lists, cards, schedule entries, check-in answers, chat lines, files/documents/uploads/vaults (including the file, docs, documents, vault, and folders aliases), Doors, projects, templates, notes, gauges, and timesheet entries, and checks the whole record rather than only body edits. User and agent guidance now instructs reading the record's updated_at and passing it unchanged as the baseline.

Limits

  • No baseline, including first use, leaves writes unprotected; a supplied timestamp is not remembered.
  • GET then PUT is not atomic, so an edit after the check can still be lost.
  • --force requires a baseline and does not waive invalid input or unavailable live state.
  • Boost editing is explicitly excluded and unchanged because its API/SDK representation has no updated_at.
  • Raw API, MCP/TUI/SDK callers, creation commands, and other update paths stay unguarded.