mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 14:57:15 +00:00
bc329fb7f4
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
71 lines
2.6 KiB
Go
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
|
|
}
|