Files
pad/internal/server/content_write_guard.go
T
xarmian bc329fb7f4 feat(items): agent body writes are version-checked by default, phase 1: count, warn, replace (PLAN-3555) (#2054)
Phase 1 of the ruling that agent content writes carry the version the agent
read. Nothing is refused yet; phase 2 (428 at the agent doors) follows a week
of these counts.

- server: `replace` request member; pad_content_writes_unguarded_total
  {door,kind}; warnings.unguarded_content_write on an agent door's unguarded
  content write; a seq update_conflict on a content write (applier route
  included) carries content_write, changed_at, changed_by_kind and a
  "do not resend your body" message.
- CLI: `pad item update --replace` (refused with a token or without a body);
  a stderr warning on an unguarded body write; the merge line on conflict.
- MCP 0.86 (additive): pad_item.update `replace`; the content-conflict hint
  on both transports; the update description leads with read, edit, write
  with the seq.
- Docs and prompts: SKILL.md (both copies), instructions.md, api.md,
  mcp.md, deployment.md, README, update help, plan and onboard playbooks.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 05:29:53 -04:00

71 lines
2.6 KiB
Go

package server
import (
"net/http"
"github.com/PerpetualSoftware/pad/internal/models"
)
// PLAN-3555 phase 1: agent content writes become version-checked by default.
// In this phase nothing is refused. An unguarded write (no expected_seq, no
// expected_updated_at, no replace) is counted by door and kind, so phase 2's
// server-side refusal (428) is decided on measured traffic, and a content write
// from a door other than the web is told so in warnings.unguarded_content_write.
// Doors a write can arrive through, bounded for a metric label.
const (
writeDoorWeb = "web" // a browser session (cookie)
writeDoorCLI = "cli" // the pad CLI, including local stdio MCP, which runs it
writeDoorAPI = "api" // a token-authenticated caller that is not the CLI
writeDoorMCPRemote = "mcp_remote" // remote MCP over /mcp
writeDoorChatGPT = "chatgpt" // the ChatGPT catalog
)
// Kinds of item update, by what the write can clobber.
const (
writeKindContent = "content" // the body
writeKindFields = "fields" // title, fields, tags, parent, assignment and other row members
)
// writeDoor names the door r arrived through. The ChatGPT surface is checked
// before remote MCP because it is one; the CLI before the generic token check
// because it authenticates with one.
func writeDoor(r *http.Request) string {
if isChatGPTSurface(r) {
return writeDoorChatGPT
}
if _, ok := remoteMCPCaller(r); ok {
return writeDoorMCPRemote
}
if r.Header.Get("X-Pad-Client") == "cli" {
return writeDoorCLI
}
if r.Header.Get("Authorization") != "" || isAPITokenAuth(r) {
return writeDoorAPI
}
return writeDoorWeb
}
// updateWriteKind classifies an item update. An update that only appends
// (implementation notes, decisions, a comment) or carries nothing a later
// write could have changed returns "": the ruling exempts append-only
// operations, because they cannot clobber anything.
func updateWriteKind(in *models.ItemUpdate) string {
if in.Content != nil {
return writeKindContent
}
if in.Title != nil || in.Fields != nil || in.FieldsPatch != nil || in.Tags != nil ||
in.Pinned != nil || in.SortOrder != nil || in.ParentID != nil ||
in.AssignedUserID != nil || in.AgentRoleID != nil || in.ClearAssignedUser ||
in.ClearAgentRole || in.GitHubPR != nil || in.ClearGitHubPR {
return writeKindFields
}
return ""
}
// updateIsUnguarded reports whether an update carries neither a version token
// nor the deliberate-overwrite opt-out.
func updateIsUnguarded(in *models.ItemUpdate) bool {
return in.ExpectedSeq == nil && in.ExpectedUpdatedAt == "" && !in.Replace
}