Files
pad/internal/server/handlers_items_patch.go
T
xarmian 5caf11f829 fix(collab): a REST content replacement fences tabs seeded from the old body, as a restore does (BUG-3746) (#2134)
* fix(collab): a REST content replacement fences tabs seeded from the old body, as a restore does (BUG-3746)

A prune-and-replace content write (content route and patch route, via
PruneAndApply) pruned the op-log and replaced items.content, but stamped
no seed boundary. A browser that fetched body A before the write and
joined after it, with since=0 and its old content_seq, was admitted,
seeded A into the empty document and flushed it over B. Join already
force-refreshes a client whose content_seq predates the last restore
(BUG-2264); replacements now stamp that same boundary: MarkRestoreBoundary
on the write (durable last_restore_seq, in its tx) and SetLastRestoreSeq
in memory, under the item lock Join takes, so no join lands in between.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(collab): the BUG-3746 boundary test uses a collab-enabled server (BUG-3746)

testServer has no collab manager, so the PATCH took the ordinary write
path, never reached PruneAndApply, and the test failed with the fix in
place (CI on #2134). Production always runs with collab; the test now
does too. The seeded op-log row is dropped: the boundary does not depend
on it.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(collab): the patch route has its own seed-boundary leg (BUG-3746)

The patch route prunes and replaces through its own PruneAndApply call,
wired separately from the content route, so a future edit dropping
fenceSeedsBefore there would pass the content-route test. Its twin
asserts the boundary moves past the seeded body's seq to the patch's.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(collab): a REST replacement admits a tab seeded at the new seq and refuses one seeded before it, through a real join (BUG-3746)

Heron, GO-blocking on #2134: the fence must not over-fence, or every fresh
tab after a REST replace would be force-refreshed, possibly in a loop. Per
route (CONVE-19), after the replacement a tab joining with
content_seq = the new seq gets op_log_cursor, and one at seq - 1 gets
force_refresh.

Mutant: the in-memory boundary stamped one past the write fails both admit
legs ("the fence over-fences"). Counterfactual: with the fence removed, both
refusal legs fail.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 08:39:02 -04:00

700 lines
27 KiB
Go

package server
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"github.com/PerpetualSoftware/pad/internal/collab"
"github.com/go-chi/chi/v5"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/PerpetualSoftware/pad/internal/bodypatch"
"github.com/PerpetualSoftware/pad/internal/diff"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
"github.com/google/uuid"
)
// PLAN-3578 step 1: content-anchored patch writes on an item with no live
// editor. POST /workspaces/{ws}/items/{ref}/patch {edits, expected_seq?}.
//
// A patch is an ordinary PATCH underneath (updateItem, through the same hook a
// built-in update uses), so it takes every guard a PATCH takes. What differs:
// - the body is computed inside the store transaction from the row it
// replaces (models.ItemUpdate.ContentTransform), so anchors match the
// committed body, never an earlier read;
// - expected_seq does not refuse: anchors are the precondition (ruling iii).
// A moved seq is applied over and reported as rebased, with the
// intervening writes and the diff;
// - an item with a live editor is refused (patch_room_open). The open-room
// path is step 2; a whole-document replace is never the fallback;
// - unflushed editor edits refuse it, token or not: items.content is then
// behind the document, and the anchors would match stale text.
type patchRequest struct {
Edits []bodypatch.Edit `json:"edits"`
ExpectedSeq *int64 `json:"expected_seq,omitempty"`
// Undo names a patch to invert (PLAN-3578 step 3) instead of edits.
Undo string `json:"undo,omitempty"`
// OpKey is the idempotency key in the body (TASK-3585), for callers that
// cannot set the Idempotency-Key header (the CLI, MCP). Not part of the
// request hash.
OpKey string `json:"op_key,omitempty"`
}
// itemPatch carries one patch through updateItem. The transform fills the
// in-transaction fields; the response and the activity read them.
type itemPatch struct {
id string
edits []bodypatch.Edit
expectedSeq *int64
baseSeq int64 // the row's seq inside the transaction, before the write
before string // the body the edits applied to
after string // the body they produced
// undoOf is the patch this one inverts, and undoItemID its item: an undo
// applies only to the item the patch was made on.
undoOf string
undoItemID string
// record is this patch's item_patches row; the transform (or the room
// path's post-ack write) fills its edits with context.
record *models.ItemPatchRecord
// idem is the claimed idempotency key, when the caller sent one
// (TASK-3585). retryConn is set on the retry of an unknown outcome: the
// only connection that may apply it, never re-planned elsewhere.
idem *idemClaim
retryConn string
baseBody string
}
// idemClaim is a request's hold on its idempotency key (TASK-3585).
type idemClaim struct {
workspaceID, userID, key, token string
}
// idempotencyRoutePatch names the patch route in the key's request hash.
const idempotencyRoutePatch = "item.patch"
// idempotencyLease bounds how long a claim blocks a concurrent retry; a
// claim older than this committed nothing (the done write shares the
// write's transaction) and may be taken over.
const idempotencyLease = 60 * time.Second
// completeIdempotencyTx is the AfterWriteTx that records the patch's answer
// with its key, inside the write's transaction.
func (s *Server) completeIdempotencyTx(p *itemPatch, pendingFlush bool) func(*sql.Tx, *models.Item) error {
return func(tx *sql.Tx, updated *models.Item) error {
b, err := json.Marshal(patchCore(updated, p, pendingFlush))
if err != nil {
return err
}
return s.store.CompleteIdempotencyKeyTx(tx, p.idem.workspaceID, p.idem.userID, p.idem.key, p.idem.token, http.StatusOK, string(b))
}
}
// idempotencyKey reads the caller's key: the header, else op_key. Empty when
// none; an error when it is malformed.
func idempotencyKey(r *http.Request, opKey string) (string, error) {
k := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if k == "" {
k = strings.TrimSpace(opKey)
}
if k == "" {
return "", nil
}
if len(k) > 200 {
return "", fmt.Errorf("idempotency key is %d characters; at most 200", len(k))
}
for _, c := range k {
if c < 0x21 || c > 0x7e {
return "", fmt.Errorf("idempotency key must be printable ASCII without spaces")
}
}
return k, nil
}
// errPatchNotFound: an undo named a patch this item does not have.
var errPatchNotFound = errors.New("patch not found on this item")
// recordEdits stores the applied edits on the patch record.
func (p *itemPatch) recordEdits(applied []bodypatch.AppliedEdit) error {
b, err := json.Marshal(applied)
if err != nil {
return err
}
p.record.Edits = string(b)
return nil
}
// maxPatchIntervening caps the intervening writes a rebased patch lists
// (lead ruling); the diff is the real check.
const maxPatchIntervening = 50
func (s *Server) handlePatchItem(w http.ResponseWriter, r *http.Request) {
var req patchRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
p := &itemPatch{id: uuid.NewString(), expectedSeq: req.ExpectedSeq}
if req.Undo != "" {
if len(req.Edits) > 0 {
writeError(w, http.StatusBadRequest, "bad_request", "undo conflicts with edits: send one")
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
rec, err := s.store.GetItemPatch(workspaceID, req.Undo)
if err != nil {
writeInternalError(w, err)
return
}
var applied []bodypatch.AppliedEdit
if rec == nil || json.Unmarshal([]byte(rec.Edits), &applied) != nil || len(applied) == 0 {
writeError2(w, http.StatusNotFound, "patch_not_found", "no such patch on this item", map[string]interface{}{"undo": req.Undo})
return
}
req.Edits = bodypatch.Invert(applied)
for _, e := range req.Edits {
if e.Old == "" {
// The patch emptied the whole body: no text is left for an
// anchored undo to name (retro CONVE-735 finding on #2058).
// Every patch writes a version, so History can restore it.
writeError2(w, http.StatusBadRequest, "bad_request",
"that patch emptied the whole body, so there is no text left to anchor an undo on; nothing was written. Restore the version before it from the item's history instead",
map[string]interface{}{"undo": req.Undo})
return
}
}
p.undoOf, p.undoItemID = rec.ID, rec.ItemID
}
if len(req.Edits) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "edits is required: at least one {old, new}, or undo")
return
}
if len(req.Edits) > bodypatch.MaxEdits {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%d edits; at most %d per patch", len(req.Edits), bodypatch.MaxEdits))
return
}
for i, e := range req.Edits {
if e.Old == "" {
writeError2(w, http.StatusBadRequest, "bad_request",
fmt.Sprintf("edit %d: old is empty; every edit must quote the text it replaces", i),
map[string]interface{}{"edit": i})
return
}
// A no-op edit would advance seq and write no version, against the
// contract that every patch is a version (retro CONVE-735 finding on
// #2055). It is refused, writing nothing.
if e.Old == e.New {
writeError2(w, http.StatusBadRequest, "bad_request",
fmt.Sprintf("edit %d changes nothing (old and new are the same); nothing was written", i),
map[string]interface{}{"edit": i})
return
}
}
p.edits = req.Edits
key, kerr := idempotencyKey(r, req.OpKey)
if kerr != nil {
writeError(w, http.StatusBadRequest, "bad_request", kerr.Error())
return
}
if key != "" {
if !s.claimPatchKey(w, r, &req, p, key) {
return
}
// Whatever the outcome below, a claim this request still holds is
// given up: a refused write releases the key for a corrected retry;
// a committed one is already done and matches nothing; an unknown
// one keeps its record and only drops the lease.
defer func() { _ = s.store.ReleaseIdempotencyKey(p.idem.workspaceID, p.idem.userID, p.idem.key, p.idem.token) }()
if p.retryConn != "" && s.collab == nil {
s.writePatchOutcomeUnknown(w, r, p)
return
}
}
p.record = &models.ItemPatchRecord{ID: p.id}
summary := fmt.Sprintf("patch: %d edit(s)", len(req.Edits))
if p.undoOf != "" {
summary = "undo patch " + p.undoOf
}
input := models.ItemUpdate{
ContentTransform: func(existing *models.Item) (string, error) {
if p.undoItemID != "" && p.undoItemID != existing.ID {
return "", errPatchNotFound
}
after, applied, err := bodypatch.ApplyWithContext(existing.Content, p.edits)
if err != nil {
return "", err
}
if err := p.recordEdits(applied); err != nil {
return "", err
}
p.baseSeq, p.before, p.after = existing.Seq, existing.Content, after
return after, nil
},
PatchRecord: p.record,
// Every patch is a version, past the throttle, so it can be undone
// from History (as the ChatGPT door's content writes are).
ForceVersion: true,
// Refuse over unflushed editor edits whether or not a token came.
RefusePendingEdits: true,
ChangeSummary: summary,
}
if p.idem != nil {
input.AfterWriteTx = s.completeIdempotencyTx(p, false)
}
s.updateItem(w, r, &builtinItemUpdate{input: input, patch: p})
}
// writePatchRefusal answers a bodypatch refusal with its typed 409; false
// when err is not one.
func (s *Server) writePatchRefusal(w http.ResponseWriter, ref string, err error) bool {
var missing *bodypatch.AnchorMissingError
if errors.As(err, &missing) {
s.countPatchRefusal("anchor_missing")
writeError2(w, http.StatusConflict, "patch_anchor_missing",
fmt.Sprintf("%s: edit %d's old text is not in the current body; re-read it and quote text that is there", ref, missing.Index),
map[string]interface{}{"ref": ref, "edit": missing.Index})
return true
}
var amb *bodypatch.AnchorAmbiguousError
if errors.As(err, &amb) {
s.countPatchRefusal("anchor_ambiguous")
writeError2(w, http.StatusConflict, "patch_anchor_ambiguous",
fmt.Sprintf("%s: edit %d's old text occurs %d times; quote more surrounding text, or pass line_hint", ref, amb.Index, len(amb.Lines)),
map[string]interface{}{"ref": ref, "edit": amb.Index, "lines": amb.Lines})
return true
}
if errors.Is(err, errPatchNotFound) {
writeError2(w, http.StatusNotFound, "patch_not_found", ref+": no such patch on this item", map[string]interface{}{"ref": ref})
return true
}
var empty *bodypatch.EmptyAnchorError
if errors.As(err, &empty) {
writeError2(w, http.StatusBadRequest, "bad_request", empty.Error(), map[string]interface{}{"edit": empty.Index})
return true
}
if errors.Is(err, bodypatch.ErrNoChange) {
writeError2(w, http.StatusBadRequest, "bad_request", ref+": the edits change nothing (together they leave the body as it was); nothing was written", map[string]interface{}{"ref": ref})
return true
}
return false
}
func writePatchRoomOpen(w http.ResponseWriter, ref string) {
writeError2(w, http.StatusConflict, "patch_room_open",
fmt.Sprintf("%s is open in an editor; a patch to an open item is not supported yet. Retry once the editor closes, or send a whole-body update", ref),
map[string]interface{}{"ref": ref})
}
// patchRoute is routeContentUpdate for a patch: the same settle. A room with
// a live writer gets the patch through a patch-capable applier tab (step 2);
// with no live writer the patch is computed in the store transaction (step 1).
func (s *Server) patchRoute(
w http.ResponseWriter,
r *http.Request,
item *models.Item,
input *models.ItemUpdate,
precheck func(*sql.Tx, *models.Item) error,
parentLink *store.ParentLinkUpdate,
pruned *int,
p *itemPatch,
) (contentRoute, *models.Item) {
var updated *models.Item
outcome, paErr := settleContentRoute(
r.Context(),
func() bool { return s.collab.HasElectableApplier(item.ID) },
func() error {
return s.collab.PruneAndApply(item.ID, func() error {
pc := composePruneWithPrecheck(s, item.ID, precheck, pruned)
u, uerr := s.store.UpdateItemWithParentLink(item.ID, fenceSeedsBefore(*input), pc, parentLink)
if uerr != nil {
return uerr
}
s.noteSeedBoundary(item.ID, u)
updated = u
return nil
})
},
applierSettleBudget, applierSettlePoll,
)
switch outcome {
case settleElectApplier:
return s.patchViaApplier(w, item, input, parentLink, p)
case settleDirectWrote:
return contentRouteDirectWrote, updated
case settleUnsettled:
writeRoomSettlingError(w, itemRefOrSlug(*item))
return contentRouteHandled, nil
default:
if s.writeTypedItemRefusal(w, item, paErr, input.ChatGPTDoor) {
return contentRouteHandled, nil
}
writeInternalError(w, paErr)
return contentRouteHandled, nil
}
}
// patchActivityMeta adds the patch's own keys to the update's activity
// entry: its id, how many edits, and whether it rebased.
func (p *itemPatch) activityMeta(meta string) string {
meta = withActivityMetaKey(meta, "patch_id", p.id)
meta = withActivityMetaKey(meta, "patch_edits", strconv.Itoa(len(p.edits)))
if p.undoOf != "" {
meta = withActivityMetaKey(meta, "patch_undo_of", p.undoOf)
}
if p.rebased() {
meta = withActivityMetaKey(meta, "patch_rebased_from_seq", strconv.FormatInt(*p.expectedSeq, 10))
}
return meta
}
func (p *itemPatch) rebased() bool {
return p.expectedSeq != nil && *p.expectedSeq != p.baseSeq
}
// patchResponse is the answer to a patch.
type patchResponse struct {
Ref string `json:"ref"`
Seq int64 `json:"seq"`
PatchID string `json:"patch_id"`
UndoOf string `json:"undo_of,omitempty"`
VersionID string `json:"version_id,omitempty"`
Rebased bool `json:"rebased"`
// AppliedOverSeq is the seq the patch applied over (TASK-3585: a
// replay recomputes its intervening writes from it).
AppliedOverSeq int64 `json:"applied_over_seq,omitempty"`
// ContentState is "applied_pending_flush" when an open editor applied
// the patch: the row catches up on that tab\'s next flush.
ContentState string `json:"content_state,omitempty"`
Intervening *models.PatchIntervening `json:"intervening,omitempty"`
Diff string `json:"diff"`
}
// patchCore is a patch's answer without its intervening writes: what an
// idempotency key stores (TASK-3585); intervening is computed live.
func patchCore(updated *models.Item, p *itemPatch, pendingFlush bool) patchResponse {
resp := patchResponse{
Ref: itemRefOrSlug(*updated),
Seq: updated.Seq,
PatchID: p.id,
UndoOf: p.undoOf,
VersionID: updated.CreatedVersionID,
Rebased: p.rebased(),
AppliedOverSeq: p.baseSeq,
Diff: diff.Unified(p.before, p.after, 3),
}
if pendingFlush {
resp.ContentState = contentOutcomeAppliedPendingFlush
}
return resp
}
func (s *Server) writePatchResponse(w http.ResponseWriter, updated *models.Item, p *itemPatch, pendingFlush bool) {
resp := patchCore(updated, p, pendingFlush)
if resp.Rebased {
iv, err := s.store.ItemSeqChain(updated.ID, *p.expectedSeq, p.baseSeq, maxPatchIntervening)
if err != nil {
// The write committed; an unreadable chain is reported as
// incomplete rather than failing an answer about a done write.
iv = &models.PatchIntervening{FromSeq: *p.expectedSeq, ToSeq: p.baseSeq}
}
resp.Intervening = iv
}
writeJSON(w, http.StatusOK, resp)
}
// stampSeqChain records on an item activity entry the write's place in the
// item's seq chain (PLAN-3578): it moved the item from prev to seq, and wrote
// versionID when non-empty.
func stampSeqChain(meta string, prev, seq int64, versionID string) string {
meta = withActivityMetaKey(meta, store.ActivityMetaPrevSeq, strconv.FormatInt(prev, 10))
meta = withActivityMetaKey(meta, store.ActivityMetaSeq, strconv.FormatInt(seq, 10))
if versionID != "" {
meta = withActivityMetaKey(meta, store.ActivityMetaVersionID, versionID)
}
return meta
}
// patchViaApplier sends the patch to an open editor (PLAN-3578 step 2), which
// applies it to the LIVE document as a narrowed replace and reports the
// canonical before/after. Unflushed editor edits do not refuse it: the tab
// applies to the document that holds them. Once the tab confirms, a row write
// records the patched body as a version attributed to this request
// (ExternalContent, as the markdown applier path does since BUG-3327) and
// moves the seq, so the activity entry joins the seq chain. A patch carries
// no field changes, so nothing can refuse that write after the apply.
func (s *Server) patchViaApplier(w http.ResponseWriter, item *models.Item, input *models.ItemUpdate, parentLink *store.ParentLinkUpdate, p *itemPatch) (contentRoute, *models.Item) {
ref := itemRefOrSlug(*item)
if p.undoItemID != "" && p.undoItemID != item.ID {
s.writePatchRefusal(w, ref, errPatchNotFound)
return contentRouteHandled, nil
}
edits, err := json.Marshal(p.edits)
if err != nil {
writeInternalError(w, err)
return contentRouteHandled, nil
}
var ack *collab.PatchAck
var aerr error
if p.retryConn != "" {
// TASK-3585: only the connection that received it may resolve it.
ack, aerr = s.collab.ApplyExternalPatchOn(item.ID, p.id, edits, p.retryConn)
if errors.Is(aerr, collab.ErrNoApplierAvailable) || errors.Is(aerr, collab.ErrNoActiveRoom) {
s.writePatchOutcomeUnknownFor(w, item, p)
return contentRouteHandled, nil
}
} else {
ack, aerr = s.collab.ApplyExternalPatch(item.ID, p.id, edits)
}
if aerr != nil {
var unknown *collab.PatchOutcomeUnknownError
if errors.As(aerr, &unknown) && p.idem != nil {
// Recorded so a retry with the same key goes back to this
// connection, whose tab re-acks a patch it applied.
base := p.baseBody
if p.retryConn == "" {
base = item.Content
p.baseSeq = item.Seq
}
if merr := s.store.MarkIdempotencyKeyUnknown(p.idem.workspaceID, p.idem.userID, p.idem.key, p.idem.token, p.id, unknown.ConnID, p.baseSeq, base); merr != nil {
slog.Error("idempotency: could not record an unknown patch outcome", "item_id", item.ID, "error", merr)
}
}
var refused *collab.PatchRefusedError
switch {
case errors.As(aerr, &refused):
s.writeTabPatchRefusal(w, ref, refused)
case errors.Is(aerr, collab.ErrNoActiveRoom), errors.Is(aerr, collab.ErrNoApplierAvailable):
// No patch-capable editor (an older tab, or the room emptied):
// nothing was sent.
s.countPatchRefusal("room_open")
writePatchRoomOpen(w, ref)
case errors.Is(aerr, collab.ErrApplierAmbiguous):
writeError(w, http.StatusConflict, "applier_ambiguous",
"A concurrent version restore made this patch's outcome ambiguous; re-read the item before retrying.")
default:
// Timed out, or the tab acknowledged without bodies: the patch may
// have landed. Say so rather than claim either way.
slog.Warn("collab: patch apply did not confirm", "item_id", item.ID, "error", aerr)
writeError2(w, http.StatusConflict, "content_not_applied",
ref+": the open editor did not confirm the patch; it may or may not have applied. Re-read the item before retrying.",
map[string]interface{}{"ref": ref, "content_outcome": contentOutcomeUnknown})
}
return contentRouteHandled, nil
}
if p.retryConn == "" {
p.baseSeq = item.Seq
}
p.before, p.after = ack.Before, ack.After
// Edits that cancel out change nothing in the tab either: refuse, writing
// nothing, as the no-room path does (retro Codex round on the #2055 fix:
// this path used to write a version and advance seq for a no-op).
if ack.Before == ack.After {
s.writePatchRefusal(w, ref, bodypatch.ErrNoChange)
return contentRouteHandled, nil
}
// The tab applied the edits to its canonical before under the same rules;
// the server re-derives each edit's context from that text so the record
// can be undone. A mismatch would be a client defect: recorded, logged.
if again, applied, rerr := bodypatch.ApplyWithContext(ack.Before, p.edits); rerr == nil {
if again != ack.After {
slog.Warn("collab: tab's patched body differs from the server's re-derivation", "item_id", item.ID, "patch_id", p.id)
}
_ = p.recordEdits(applied)
}
row := *input
row.ContentTransform = nil
row.Content = nil
row.RefusePendingEdits = false
row.ExternalContent = &ack.After
if p.record.Edits == "" {
row.PatchRecord = nil // nothing to undo from; the patch still stands
}
row.AfterWriteTx = nil
if p.idem != nil {
row.AfterWriteTx = s.completeIdempotencyTx(p, true)
}
updated, uerr := s.store.UpdateItemWithParentLink(item.ID, row, nil, parentLink)
if uerr != nil {
// The document has the patch; only its version row is missing.
slog.Error("collab: patch applied but its version row failed", "item_id", item.ID, "error", uerr)
writeInternalError(w, uerr)
return contentRouteHandled, nil
}
return contentRouteApplierWrote, updated
}
// writeTabPatchRefusal answers a refusal the applier tab made.
func (s *Server) writeTabPatchRefusal(w http.ResponseWriter, ref string, e *collab.PatchRefusedError) {
s.countPatchRefusal(e.Reason)
details := map[string]interface{}{"ref": ref}
if e.Edit != nil {
details["edit"] = *e.Edit
}
if len(e.Lines) > 0 {
details["lines"] = e.Lines
}
if e.Line > 0 {
details["line"] = e.Line
}
switch e.Reason {
case "anchor_missing":
writeError2(w, http.StatusConflict, "patch_anchor_missing", ref+": an edit's old text is not in the open editor's current body; re-read it and quote text that is there", details)
case "anchor_ambiguous":
writeError2(w, http.StatusConflict, "patch_anchor_ambiguous", ref+": an edit's old text occurs more than once in the open editor's body; quote more surrounding text, or pass line_hint", details)
default:
// unstable_roundtrip, parse_refused, or a reason this server does not
// know: the tab could not apply the patch without changing text the
// agent never saw.
details["reason"] = e.Reason
writeError2(w, http.StatusConflict, "patch_unstable_roundtrip",
ref+": the open editor cannot apply this patch without changing content you did not edit; send a whole-body update with expected_seq instead",
details)
}
}
// countPatchRefusal counts a refused patch by reason (lead ruling: the
// uncovered share must be visible).
func (s *Server) countPatchRefusal(reason string) {
if s.metrics == nil {
return
}
switch reason {
case "anchor_missing", "anchor_ambiguous", "unstable_roundtrip", "parse_refused", "room_open", "empty_anchor", "outcome_unknown":
default:
reason = "other"
}
s.metrics.PatchRefusalsTotal.WithLabelValues(reason).Inc()
}
// claimPatchKey claims the request's idempotency key (TASK-3585) BEFORE any
// write-path guard, so a retry of a write that committed replays it rather
// than meeting its own first success as a conflict (lead ruling). It answers
// the request itself, returning false, when the key settles it: a replay, a
// key reused for a different request (422), or one in flight (409).
func (s *Server) claimPatchKey(w http.ResponseWriter, r *http.Request, req *patchRequest, p *itemPatch, key string) bool {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return false
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return false
}
if item == nil {
s.writeItemResolveError(w, r, workspaceID, itemSlug)
return false
}
if !s.requireItemVisible(w, r, workspaceID, item) || !s.requireEditPermission(w, r, workspaceID, item.ID, item.CollectionID) {
return false
}
canon := *req
canon.OpKey = ""
body, _ := json.Marshal(canon)
sum := sha256.Sum256([]byte(idempotencyRoutePatch + "\x00" + item.ID + "\x00" + string(body)))
userID := currentUserID(r)
c, err := s.store.ClaimIdempotencyKey(workspaceID, userID, key, idempotencyRoutePatch, hex.EncodeToString(sum[:]), idempotencyLease)
if err != nil {
writeInternalError(w, err)
return false
}
switch c.Outcome {
case store.IdemReplay:
var core patchResponse
if json.Unmarshal([]byte(c.Response), &core) != nil {
writeInternalError(w, fmt.Errorf("stored idempotent answer does not decode"))
return false
}
if core.Rebased && req.ExpectedSeq != nil {
if iv, ierr := s.store.ItemSeqChain(item.ID, *req.ExpectedSeq, core.AppliedOverSeq, maxPatchIntervening); ierr == nil {
core.Intervening = iv
}
}
w.Header().Set("Idempotent-Replayed", "true")
writeJSON(w, c.Status, core)
return false
case store.IdemMismatch:
writeError2(w, http.StatusUnprocessableEntity, "idempotency_key_reused",
"this idempotency key was already used for a different request; use a new key", map[string]interface{}{"key": key})
return false
case store.IdemInFlight:
w.Header().Set("Retry-After", "2")
writeError2(w, http.StatusConflict, "idempotency_in_flight",
"a request with this idempotency key is still in progress; retry shortly with the same key", map[string]interface{}{"key": key})
return false
case store.IdemUnknown:
p.id = c.PatchID
p.retryConn = c.ConnID
p.baseSeq = c.BaseSeq
p.baseBody = c.BaseBody
}
p.idem = &idemClaim{workspaceID: workspaceID, userID: userID, key: key, token: c.Token}
return true
}
// writePatchOutcomeUnknownFor is writePatchOutcomeUnknown for a resolved item
// (the room path), reading the item fresh for the current seq and body.
func (s *Server) writePatchOutcomeUnknownFor(w http.ResponseWriter, item *models.Item, p *itemPatch) {
cur, err := s.store.GetItem(item.ID)
if err != nil || cur == nil {
writeInternalError(w, fmt.Errorf("read item for an unknown patch outcome: %v", err))
return
}
s.countPatchRefusal("outcome_unknown")
ref := itemRefOrSlug(*cur)
details := map[string]interface{}{
"ref": ref, "patch_id": p.id, "seq": cur.Seq, "base_seq": p.baseSeq,
"diff": diff.Unified(p.baseBody, cur.Content, 3),
}
if cur.ContentState != "" {
details["content_state"] = cur.ContentState
}
writeError2(w, http.StatusConflict, "patch_outcome_unknown",
ref+": an earlier attempt of this patch reached an editor that never confirmed it, and that editor is gone, so it is not re-applied. Read the diff since then: if your edit is there, it landed",
details)
}
// writePatchOutcomeUnknown answers a retry of an unconfirmed patch whose
// connection is gone (TASK-3585, lead ruling): it is NOT re-planned (an
// insert-shaped edit keeps its anchor and would apply twice). The caller gets
// the current seq and the diff from the body the patch was sent against to
// now, and decides; the key stays unknown.
func (s *Server) writePatchOutcomeUnknown(w http.ResponseWriter, r *http.Request, p *itemPatch) {
s.countPatchRefusal("outcome_unknown")
workspaceID, _ := s.getWorkspaceID(w, r)
item, err := s.store.ResolveItem(workspaceID, chi.URLParam(r, "itemSlug"))
if err != nil || item == nil {
writeInternalError(w, fmt.Errorf("resolve item for an unknown patch outcome: %v", err))
return
}
ref := itemRefOrSlug(*item)
details := map[string]interface{}{
"ref": ref,
"patch_id": p.id,
"seq": item.Seq,
"base_seq": p.baseSeq,
"diff": diff.Unified(p.baseBody, item.Content, 3),
}
if item.ContentState != "" {
details["content_state"] = item.ContentState
}
writeError2(w, http.StatusConflict, "patch_outcome_unknown",
ref+": an earlier attempt of this patch reached an editor that never confirmed it, and that editor is gone, so it is not re-applied. Read the diff since then: if your edit is there, it landed",
details)
}