Files
pad/internal/server/handlers_items.go
T
xarmian e598bc4f64 feat(decision): effort, effort_medium and high_stakes on the attention set, staged; drain-paced backfill (TASK-3921) (#2236)
* feat(decision): effort, effort_medium and high_stakes on the attention set, staged; one-shot backfill (TASK-3921)

- attention set gains effort (Score), effort_medium (Choice) and high_stakes
  (Noul) in the same call; needs_human_decision's text is unchanged.
- QuestionSet.Unsurfaced names the staged keys: WorkspaceFlags and
  SurfacedDecisions skip them (no flag, no state read, no counter); the
  decisions list and endpoint still return every row.
- attention chips only render keys that have a label.
- store: per-set enqueue body extracted (owedDecisionJobTx), OweDecisionJobs
  (chunked, under the installed debounce) and ItemsMissingDecisionKey.
- Runner.BackfillEffort owes the attention set once for eligible items with a
  needs_human_decision answer and no effort answer, marker
  effort_decisions_backfilled written after every chunk; hooked at the end of
  reconfigureDecisionsLocked (boot and later admin enable); makes no provider call.
- eval harness: titlebody now sends the live set; RESULTS.md rows pending.
- docs/api.md and tests updated.

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

* test(decision-eval): keep titlebody as the day-73 control, add titlebody-live; record the TASK-3957 and TASK-3921 runs

- titlebody again sends the day-73 pair (needs_human_decision + blocked), so it
  stays comparable with its 09-22 row as the model-drift control.
- titlebody-live sends the live attention set on the same {title, body} state:
  the TASK-3921 gate (needs_human_decision AUC 0.936, within .02 of 0.939).
- Every run prints the question keys it sends, before -dry too, so a row's
  call shape is in its own log.
- RESULTS.md: rows for the one-question call (TASK-3957) and the
  four-question call (TASK-3921).

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

* fix(decision): effort backfill lifecycle, staged keys off item reads, dry-run guard (TASK-3921; #r1 F1-F4)

- F1: startEffortBackfill checks stopped and does bg.Add under one
  decisionTick.mu hold; BackfillEffort/EffortBackfillCandidates take a ctx
  checked before each candidate read and each chunk; cancel returns ctx.Err()
  and never writes the marker. stopDecisionTick cancels the run.
- F2: reconfigure cancels a running backfill; only a non-nil runner starts a
  new one. A per-run generation replaces the atomic latch so a cancelled run
  cannot clobber its successor.
- F3: item show / --agent / MCP item get use Runner.ItemReadDecisions, which
  drops the set's Unsurfaced keys; the decisions endpoint stays raw. docs/api.md
  updated.
- F4: the file dry run refuses a path with a -wal/-shm sibling or under
  $HOME/.pad (refuseLiveDatabase, tested without the env var).

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

* test(decision): a chunk write that fails in the store writes no marker (TASK-3921; mutant M6)

The two failure tests fail the per-chunk test hook, so the path where
OweDecisionJobs itself returns an error was never exercised: a mutant that
ignores that error and marks anyway survived. The new test breaks the
decision_jobs table from the hook so the error comes back through the store,
asserts no marker, then restores the table and re-runs to completion.

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

* refactor(decision): drop the pre-marker cancel check; a cancel after the last chunk may mark (TASK-3921; M5, lead ruling)

Mutant M5 (that check removed) survived every test, and could not do
otherwise: a cancel can land there only after every chunk was owed, and the
marker means every candidate was ENQUEUED, not answered, so marking then is
true. The check is removed rather than kept as a guard no instrument can
turn red. The doc comments on BackfillEffort, cancelEffortBackfill and
startEffortBackfill now say a cancel before the last chunk writes no
marker and one after it may.

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

* docs(api): a backfill chunk in flight when decisions are disabled may still commit (TASK-3921; r2 L1, lead ruling bdce2116)

docs/api.md said that with no provider "no job row is ever written". That is
false for one window: a disable that lands while the one-shot effort
backfill is inside a chunk's write lets that chunk commit, and its jobs wait
until a provider returns. Ruled docs-only. The disable test's comment now
says it covers a backfill parked before its first chunk, not that window.

Sweep for the claim (CONVE-23): grep of docs, internal, cmd, scripts and
skills (.go, .md) for "no job row", "job row is", "no job is", "no jobs
are written/owed/enqueued" and "never written/owed/enqueued". Two hits are
about this mechanism: api.md:72 (fixed) and the test comment (narrowed).
Unchanged, still true:
- store/decisions.go:27 is about the WRITE DOORS, which still enqueue
  nothing without a resolver.
- runner_test.go:238 is about the nil-provider runner, which never starts a
  backfill.
- effort_backfill_lifecycle_test.go:128 covers a run parked before its
  first chunk.
The other hits are unrelated mechanisms.

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

* fix(decision): pace the effort backfill on drain; dry run opens a copy; docs eligibility (TASK-3921; r3 M1, L2, L4)

- M1: the backfill owes one tick pass of items per chunk (20 default, wired
  from the tick limit) and owes chunk k+1 only once chunk k's own jobs have
  drained: none claimable now, by ClaimDecisionJobs' own predicate
  (new Store.CountClaimableDecisionJobs). Debounced jobs do not hold it.
  The wait polls at the tick interval, honours ctx, and a cancel writes
  no marker; the marker still follows the last chunk being owed.
- L2: TestEffortBackfillDryRunOnFile copies the named file under GOTMPDIR
  and opens only the copy.
- L4: docs/api.md states the real eligibility and the drain pacing.

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

* docs(decision): the Unsurfaced comment names ItemReadDecisions (TASK-3921; r4 L2)

The field's comment listed WorkspaceFlags and SurfacedDecisions as the
readers that skip staged keys, but not ItemReadDecisions, which item show
(and so --agent and MCP item get) has used since the r1 F3 fix. Comment only.

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

* test(decision): break the table before the LAST chunk so the store-failure test pins M6 (TASK-3921; lead ruling)

Since the drain pacing (51c545850), the store-failure test broke the
decision_jobs table before chunk 1 of 3. Under mutant M6 (a failed chunk
write ignored) the drain wait that follows read the same broken table and
failed, so no marker was written by a different mechanism and the test stayed
green: an end-state assertion another path also produced (CONVE-12). Breaking
the table before the last chunk leaves no drain wait after it (the marker does
not wait for the last chunk to drain), so only the chunk's own error check can
keep the marker unwritten.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-19 13:05:05 -04:00

4789 lines
194 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"maps"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/PerpetualSoftware/pad/internal/events"
"github.com/PerpetualSoftware/pad/internal/items"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
"github.com/PerpetualSoftware/pad/internal/watchevents"
)
// errStaleCollabSnapshot signals an UnderItemLock-wrapped
// collab-snapshot validation rejected the PATCH because the
// caller's op_log_cursor was incompatible with the current op-log
// (cursor>0 + empty op-log, or cursor<MIN). The handler unwraps
// this via errors.Is and returns 409 Conflict. Per Codex round 13
// [P1] of TASK-1319.
var errStaleCollabSnapshot = errors.New("collab-snapshot: cursor incompatible with op-log")
// writeSnapshotBehindContentError answers a browser snapshot refused because
// its op-log cursor is below the one the stored body covers (BUG-3744).
// Nothing was written. It is answered like a superseded write, which the pane
// treats as done rather than failed: the item already holds a newer body.
func writeSnapshotBehindContentError(w http.ResponseWriter, itemID string, err error) bool {
var behind *store.SnapshotBehindContentError
if !errors.As(err, &behind) {
return false
}
slog.Info("collab-snapshot: refused; cursor behind the stored content",
"item_id", itemID, "cursor", behind.Cursor, "covered", behind.Covered)
writeError2(w, http.StatusConflict, "superseded_write",
"a newer body is already stored; this snapshot was not written",
map[string]interface{}{"op_log_cursor": behind.Cursor, "content_op_cursor": behind.Covered})
return true
}
// handleListItems lists all items across collections in a workspace.
func (s *Server) handleListItems(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
params := parseItemListParams(r)
if err := validateUnparentedListRequest(r, params); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if err := s.resolveParentFilter(r, workspaceID, &params); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
// Apply collection visibility filter
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
params.CollectionIDs = visibleIDs
if params.Unparented && visibleIDs != nil {
writeError(w, http.StatusForbidden, "forbidden", "unparented filtering requires unrestricted workspace access")
return
}
// Apply item-level filtering for users with item grants (guests or
// restricted members) so item grants don't leak entire collections.
fullCollIDs, grantedItemIDs, grantErr := s.guestResourceFilter(r, workspaceID)
if grantErr != nil {
writeInternalError(w, grantErr)
return
}
if len(grantedItemIDs) > 0 {
params.CollectionIDs = fullCollIDs
params.ItemIDs = grantedItemIDs
}
// Every filter key must name a field (BUG-3480): here, a field some
// collection the caller can see declares.
if len(params.Fields) > 0 || len(params.FieldsAnyOf) > 0 {
colls, err := s.store.ListCollections(workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
var schemas []models.CollectionSchema
for _, c := range colls {
if !isCollectionVisible(c.ID, visibleIDs) || c.Schema == "" {
continue
}
var sc models.CollectionSchema
if models.UnmarshalItemFieldSchema([]byte(c.Schema), &sc) == nil {
schemas = append(schemas, sc)
}
}
undeclared, err := s.checkListFilterKeys(workspaceID, params, schemas, "no collection in this workspace")
if err != nil {
writeListFilterKeyError(w, err)
return
}
if len(undeclared) > 0 {
w.Header().Set(undeclaredFilterKeysHeader, strings.Join(undeclared, ","))
}
}
result, err := s.listItemsPage(w, workspaceID, params)
if err != nil {
writeInternalError(w, err)
return
}
if result == nil {
result = []models.Item{}
}
s.enrichItemsWithParent(r, workspaceID, result, visibleIDs)
// Decorate live execution leases (#1221) with ONE workspace query
// rather than widening the shared item scan: leases are absent on
// most rows, and the map only carries live ones (expiry filtered in
// SQL), so unleased items keep their key omitted.
if leases, err := s.store.ListItemLeases(workspaceID); err != nil {
writeInternalError(w, err)
return
} else if len(leases) > 0 {
for i := range result {
if lease, ok := leases[result[i].ID]; ok {
leaseCopy := lease
result[i].Lease = &leaseCopy
}
}
}
writeJSON(w, http.StatusOK, result)
}
// handleListTags returns the distinct tags used across the workspace's items
// with per-tag item counts, ordered by count desc then tag asc. Respects the
// same collection-visibility and item-grant filters as handleListItems so tag
// counts never include items the caller can't see.
func (s *Server) handleListTags(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
collIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
var itemIDs []string
fullCollIDs, grantedItemIDs, grantErr := s.guestResourceFilter(r, workspaceID)
if grantErr != nil {
writeInternalError(w, grantErr)
return
}
if len(grantedItemIDs) > 0 {
collIDs = fullCollIDs
itemIDs = grantedItemIDs
}
tags, err := s.store.ListWorkspaceTags(workspaceID, collIDs, itemIDs)
if err != nil {
writeInternalError(w, err)
return
}
if tags == nil {
tags = []models.TagCount{}
}
writeJSON(w, http.StatusOK, tags)
}
// itemsIndexResponse wraps the skinny-projection items list with bookkeeping
// the local-first read model (PLAN-1343) needs:
// - total: the row count so the client can size its in-memory array.
// - cursor: the workspace-scoped monotonic `seq` cursor (TASK-1353).
// Holds MAX(seq) across the requested scope as a decimal-encoded
// string so it forward-compatibly tolerates future encoding changes
// (opaque on the wire — clients MUST NOT parse it as an integer).
// When the requested scope returns zero rows, the cursor falls back
// to the workspace's current MAX(seq) so /items-changes?since=cursor
// starts from the right floor on the next poll instead of replaying
// every prior mutation. Empty workspaces return "0".
type itemsIndexResponse struct {
Items []models.Item `json:"items"`
Total int `json:"total"`
Cursor string `json:"cursor"`
IncludesUnparentedMetadata bool `json:"includes_unparented_metadata"`
// AccessEpoch fingerprints the caller's effective visible set so the
// client can detect a revocation that wrote no row (IDEA-2898). See
// computeAccessEpoch. Carried on BOTH doors, and they must agree: a
// cold bootstrap and a delta poll that disagreed about the caller's
// scope would resync each other in a loop.
AccessEpoch string `json:"access_epoch"`
}
// handleListItemsIndex returns the skinny-projection of every item in a
// workspace — same row set as handleListItems but without the rich-text
// `content` body. It exists to bootstrap the local-first read model
// (PLAN-1343 Phase 1): the client hydrates its in-memory index + IndexedDB
// cache from one request, then renders every collection page from local
// state without re-fetching.
//
// Filters: optional ?collection=<slug>. No pagination — callers want the
// full set. Auth: same as handleListItems (collection visibility + item
// grants).
func (s *Server) handleListItemsIndex(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
// Resolved for the same reason the collection item routes are (BUG-2578):
// the store filters by exact slug, so `?collection=spec` against a `specs`
// collection would otherwise return an empty index rather than an error.
// The web client sends canonical slugs and is unaffected; this is for
// direct API consumers. Exact match still wins, so no existing query
// changes meaning. A slug that resolves to nothing is left as the caller
// wrote it, preserving today's empty-result behaviour.
indexCollection := r.URL.Query().Get("collection")
var indexCollectionID string
if indexCollection != "" {
coll, rerr := s.resolveItemCollectionSlug(workspaceID, indexCollection)
if rerr != nil {
// Surfaced rather than swallowed: proceeding with the raw alias
// after a failed lookup would answer a database error with a
// successful EMPTY index, which reads as "no items" (codex
// round 4).
writeInternalError(w, rerr)
return
}
if coll != nil {
// Scoped by the resolved ID rather than its slug, which can be
// re-taken by another collection before the query runs
// (BUG-2631).
indexCollection = ""
indexCollectionID = coll.ID
}
}
params := store.ItemIndexParams{
CollectionSlug: indexCollection,
ScopeCollectionID: indexCollectionID,
}
if r.URL.Query().Get("include_archived") == "true" {
params.IncludeArchived = true
}
// Collection visibility filter — same shape as handleListItems.
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
params.CollectionIDs = visibleIDs
params.IncludeUnparentedMetadata = visibleIDs == nil
// Item-level grants for guests / restricted members.
fullCollIDs, grantedItemIDs, grantErr := s.guestResourceFilter(r, workspaceID)
if grantErr != nil {
writeInternalError(w, grantErr)
return
}
if len(grantedItemIDs) > 0 {
params.CollectionIDs = fullCollIDs
params.ItemIDs = grantedItemIDs
}
// Snapshot the workspace's MAX(seq) BEFORE the list query so the
// fallback cursor cannot leapfrog a concurrent insert that
// committed between the list query and a post-list MaxItemSeq
// read. Per Codex review of TASK-1353 round 1 [P1]:
//
// List → empty.
// Concurrent INSERT lands with seq = M+1 (visible to a future
// /items-changes call).
// MaxItemSeq → M+1.
// Cursor = M+1, response = [].
// Client polls /items-changes?since=M+1 → seq > M+1 returns
// nothing. The new row is lost.
//
// Capturing M BEFORE the list eliminates that window: any insert
// after the snapshot has seq > M (workspace counter is strictly
// monotonic per TASK-1352), so /items-changes?since=M will return
// it. Rows the list DOES see may have seq > M (a concurrent insert
// the list query happened to observe); MAX(rows.seq) bumps the
// cursor for that case so the client never re-fetches what was
// already in the response.
wsMaxBefore, err := s.store.MaxItemSeq(workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
// The live collection set the epoch fingerprints (BUG-3428 phase 2),
// read BEFORE the rows (codex r1). Read after them, a delete committing
// in between paired the deleted collection's rows with the post-delete
// epoch, and no later poll would evict them. Read before, the response
// carries the older epoch, so the next poll sees the change and resyncs.
liveCollIDs, liveErr := s.store.LiveCollectionIDs(workspaceID)
if liveErr != nil {
writeInternalError(w, liveErr)
return
}
result, err := s.store.ListItemsIndex(workspaceID, params)
if err != nil {
writeInternalError(w, err)
return
}
if s.itemDoorAfterRowsHook != nil {
s.itemDoorAfterRowsHook()
}
if result == nil {
result = []models.Item{}
}
s.enrichItemsWithParent(r, workspaceID, result, visibleIDs)
// Cursor: max(pre-list workspace floor, MAX(returned-rows.seq)).
// See the long comment above MaxItemSeq for why the floor is read
// BEFORE ListItemsIndex. Empty workspace + empty result → "0".
cursorSeq := wsMaxBefore
for _, it := range result {
if it.Seq > cursorSeq {
cursorSeq = it.Seq
}
}
cursor := strconv.FormatInt(cursorSeq, 10)
writeJSON(w, http.StatusOK, itemsIndexResponse{
Items: result,
Total: len(result),
Cursor: cursor,
IncludesUnparentedMetadata: params.IncludeUnparentedMetadata,
AccessEpoch: effectiveAccessEpoch(visibleIDs, fullCollIDs, grantedItemIDs, liveCollIDs),
})
}
// itemChangeRow embeds the standard skinny Item shape and adds a
// `deleted: bool` flag so clients can distinguish upserts from
// tombstones in a single response. The boolean is a derived view of
// the underlying `deleted_at` so old clients that only key off
// `deleted_at` still work — both fields are populated.
type itemChangeRow struct {
models.Item
Deleted bool `json:"deleted"`
// MovedOut marks a row the caller can no longer see because the item
// moved into a collection outside their visibility (BUG-1675). Unlike
// `deleted` (a soft-delete the item still exists under), a moved-out
// row carries only id + seq — no title/fields/destination — and the
// client evicts it from its local cache. The item is alive for
// callers who can see the destination; it's just gone from THIS
// caller's view.
MovedOut bool `json:"moved_out,omitempty"`
}
// itemsChangesResponse wraps a delta-fetch result.
//
// - changes: rows where `seq > since`, in ascending seq order. Each
// row includes `deleted` (true when the row is soft-deleted) so
// clients can apply / remove without a second roundtrip.
// - cursor: the largest seq in the response, decimal-encoded.
// When the response is empty, the server returns the caller's
// `since` unchanged so the client doesn't lose position. Treat
// the value as opaque (re-pass as `?since=<cursor>` on the next
// poll).
type itemsChangesResponse struct {
Changes []itemChangeRow `json:"changes"`
Cursor string `json:"cursor"`
IncludesUnparentedMetadata bool `json:"includes_unparented_metadata"`
// AccessEpoch — see itemsIndexResponse.AccessEpoch. This is the door
// that matters most for IDEA-2898: it is the one a warm client polls,
// and the one a revocation is otherwise invisible on.
AccessEpoch string `json:"access_epoch"`
}
// handleListItemsChanges is the delta-fetch sibling of
// handleListItemsIndex. Returns rows that have mutated since the
// caller's `?since=<seq>` cursor, including tombstones, so a
// local-first read-model client can resume a workspace without
// re-fetching the entire index (PLAN-1343 / TASK-1354).
//
// Query params:
// - since: exclusive seq lower bound (`seq > since`). Defaults to 0
// (full delta == full index modulo ordering). Decimal string;
// invalid values are 400.
// - limit: cap on returned rows. Defaults to
// store.DefaultItemChangesLimit (5000), clamped to
// store.MaxItemChangesLimit (50000). Invalid values are 400.
//
// Auth: same collection-visibility + item-grant filter as
// /items-index. Soft-deleted rows propagate so they can be removed
// from the client's local index.
func (s *Server) handleListItemsChanges(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
q := r.URL.Query()
sinceStr := q.Get("since")
var since int64
if sinceStr != "" {
v, err := strconv.ParseInt(sinceStr, 10, 64)
if err != nil || v < 0 {
writeError(w, http.StatusBadRequest, "invalid_cursor", "`since` must be a non-negative integer")
return
}
since = v
}
var limit int
if limitStr := q.Get("limit"); limitStr != "" {
v, err := strconv.Atoi(limitStr)
if err != nil || v <= 0 {
writeError(w, http.StatusBadRequest, "invalid_limit", "`limit` must be a positive integer")
return
}
limit = v
}
// Collection visibility filter — same shape as handleListItemsIndex.
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
params := store.ItemChangesParams{
CollectionIDs: visibleIDs,
Since: since,
Limit: limit,
IncludeUnparentedMetadata: visibleIDs == nil,
}
// Item-level grants for guests / restricted members. Delta sync
// uses the include-deleted-items variant so a tombstone on a
// granted item still flows through — without it the grant ID
// disappears as soon as the item is soft-deleted, and the
// client never learns the row went away (Codex review of
// TASK-1354 round 1 [P1]).
fullCollIDs, grantedItemIDs, grantErr := s.guestResourceFilterIncludeDeletedItems(r, workspaceID)
if grantErr != nil {
writeInternalError(w, grantErr)
return
}
if len(grantedItemIDs) > 0 {
params.CollectionIDs = fullCollIDs
params.ItemIDs = grantedItemIDs
}
// The live collection set the epoch fingerprints (BUG-3428 phase 2),
// read BEFORE the rows (codex r1). Read after them, a delete committing
// in between paired the deleted collection's rows with the post-delete
// epoch, and no later poll would evict them. Read before, the response
// carries the older epoch, so the next poll sees the change and resyncs.
liveCollIDs, liveErr := s.store.LiveCollectionIDs(workspaceID)
if liveErr != nil {
writeInternalError(w, liveErr)
return
}
// The LIVE grant set the epoch uses (see the note at the response).
liveFullCollIDs, liveGrantedItemIDs, liveGrantErr := s.guestResourceFilter(r, workspaceID)
if liveGrantErr != nil {
writeInternalError(w, liveGrantErr)
return
}
rows, err := s.store.ListItemsChangesSince(workspaceID, params)
if err != nil {
writeInternalError(w, err)
return
}
if s.itemDoorAfterRowsHook != nil {
s.itemDoorAfterRowsHook()
}
// Enrich with parent metadata so the local cache rows match the
// /items-index payload shape. Soft-deleted parents are skipped by
// the underlying GetItem (deleted_at IS NULL), so we don't leak
// parent title / ref after the parent itself has been archived.
s.enrichItemsWithParent(r, workspaceID, rows, visibleIDs)
changes := make([]itemChangeRow, 0, len(rows))
for _, it := range rows {
changes = append(changes, itemChangeRow{
Item: it,
Deleted: it.DeletedAt != nil,
})
}
// Moved-out tombstones (BUG-1675). Only restricted callers
// (visibleIDs != nil) can lose visibility on a move; full members
// (nil = all-access) never do, so skip the extra query for them.
// The main delta filters by current collection, so an item that
// moved into a collection this caller can't see vanishes with no
// eviction signal — append a minimal id+seq tombstone for those.
if visibleIDs != nil {
// Collections the caller browses at the collection level (the
// from-collection a move must originate in). With item grants
// the main query swaps to fullCollIDs; mirror that here.
collLevelVisibleIDs := visibleIDs
if len(grantedItemIDs) > 0 {
collLevelVisibleIDs = fullCollIDs
}
movedOut, mErr := s.store.ListMovedOutSince(workspaceID, since, limit, collLevelVisibleIDs, grantedItemIDs)
if mErr != nil {
writeInternalError(w, mErr)
return
}
for _, m := range movedOut {
changes = append(changes, itemChangeRow{
Item: models.Item{ID: m.ID, Seq: m.Seq},
MovedOut: true,
})
}
}
// Merge main + moved-out rows into one seq-ascending stream, then
// cap at the effective limit so pagination stays gap-free: the
// cursor is the last kept row's seq and any row past it is dropped
// to be re-fetched on the next poll (seq is strictly monotonic per
// workspace, so no overlap, no gap).
sort.Slice(changes, func(i, j int) bool { return changes[i].Seq < changes[j].Seq })
effLimit := limit
if effLimit <= 0 {
effLimit = store.DefaultItemChangesLimit
}
if effLimit > store.MaxItemChangesLimit {
effLimit = store.MaxItemChangesLimit
}
if len(changes) > effLimit {
changes = changes[:effLimit]
}
// Cursor: MAX(seq) in the (capped) response. When empty, return
// `since` unchanged so the client doesn't regress its position.
cursorSeq := since
for _, c := range changes {
if c.Seq > cursorSeq {
cursorSeq = c.Seq
}
}
// THE EPOCH USES THE LIVE GRANT SET, NOT THIS DOOR'S QUERY SET (review
// round 2). The query above deliberately resolves grants with deleted items
// INCLUDED, so a tombstone on a granted item still flows through
// (TASK-1354). The fingerprint must not: /items-index resolves LIVE grants,
// and a caller holding a grant on a soft-deleted item would otherwise get
// permanently disagreeing epochs from the two doors — the client resyncs,
// the snapshot restores the index door's value, the next delta contradicts
// it again, and the reconcile loop runs to its 50-page cap on every sync
// trigger, forever. Two values on two wires that are compared against each
// other have to have ONE definition, and the live set is it: it is what the
// SSE revalidation tick already holds, so all three sites agree.
//
// The cost is one extra grant resolve on this door, and the consequence is
// that soft-deleting a granted item does change the epoch once. That is a
// single spurious resync which then converges, rather than a storm that
// never does. The live COLLECTION set comes from the same resolve, because
// with item grants it is the set this door filters by (BUG-3347; see
// effectiveAccessEpoch).
// liveFullCollIDs / liveGrantedItemIDs are resolved BEFORE the rows,
// with the live collection set (codex r2): read after them, a collection
// delete in between dropped its grant from the epoch while the rows still
// held its items.
writeJSON(w, http.StatusOK, itemsChangesResponse{
Changes: changes,
Cursor: strconv.FormatInt(cursorSeq, 10),
IncludesUnparentedMetadata: params.IncludeUnparentedMetadata,
AccessEpoch: effectiveAccessEpoch(visibleIDs, liveFullCollIDs, liveGrantedItemIDs, liveCollIDs),
})
}
// handleListCollectionItems lists items within a specific collection.
func (s *Server) handleListCollectionItems(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
collSlug := chi.URLParam(r, "collSlug")
// Singular forms resolve against the workspace's real collections, so a
// template-defined or user-created collection has a shorthand too, not
// just the seven the client-side alias map knows (BUG-2578). Exact match
// still wins, so this cannot redirect a request that already worked.
coll, err := s.resolveItemCollectionSlug(workspaceID, collSlug)
if err != nil {
writeInternalError(w, err)
return
}
if coll == nil {
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
// Gate check: is this collection visible to the user?
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
if !isCollectionVisible(coll.ID, visibleIDs) {
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
params := parseItemListParams(r)
// Scope the query by the ID the visibility gate above checked, not by a
// slug (BUG-2631): a slug can be freed by a rename or delete and re-taken
// by another collection between the gate and the query, and the response
// would then carry that collection's items. (Filtering on the raw URL
// parameter was worse still — an alias resolved for the gate matched
// nothing in the query, a 200 with an empty list, BUG-2578.)
params.ScopeCollectionID = coll.ID
// DO NOT scope through params.CollectionIDs. It looks like a scoping
// filter and is not: CollectionIDs and ItemIDs are a PERMISSION PAIR,
// combined with OR ("in a fully-granted collection, or specifically
// granted"). Setting CollectionIDs here while the item-grant branch below
// sets ItemIDs turns the caller's grants into
// `collection_id IN (this) OR id IN (granted)` — which hands a guest
// holding one item grant every item in the collection. ScopeCollectionID
// is ANDed on its own and exists for exactly this.
if err := validateUnparentedListRequest(r, params); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if params.Unparented && visibleIDs != nil {
writeError(w, http.StatusForbidden, "forbidden", "unparented filtering requires unrestricted workspace access")
return
}
// For users with item-level grants, if this collection's visibility comes
// from item-level grants (not a full collection grant), restrict to only
// the granted items. Applies to both guests and restricted members.
lcFullCollIDs, lcGrantedItemIDs, lcGrantErr := s.guestResourceFilter(r, workspaceID)
if lcGrantErr != nil {
writeInternalError(w, lcGrantErr)
return
}
if len(lcGrantedItemIDs) > 0 {
hasFullCollectionGrant := false
for _, id := range lcFullCollIDs {
if id == coll.ID {
hasFullCollectionGrant = true
break
}
}
// Also check member_collection_access for restricted members
if !hasFullCollectionGrant && workspaceRole(r) != "guest" {
memberColls, _ := s.store.GetMemberCollectionAccess(workspaceID, currentUserID(r))
for _, id := range memberColls {
if id == coll.ID {
hasFullCollectionGrant = true
break
}
}
}
if !hasFullCollectionGrant {
params.ItemIDs = lcGrantedItemIDs
}
}
var collSchema models.CollectionSchema
if coll.Schema != "" {
_ = models.UnmarshalItemFieldSchema([]byte(coll.Schema), &collSchema)
}
if err := s.resolveParentFilter(r, workspaceID, &params, collSchema); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
// Every filter key must name a field this collection declares
// (BUG-3480), or one its items store.
undeclared, err := s.checkListFilterKeys(workspaceID, params, []models.CollectionSchema{collSchema}, fmt.Sprintf("collection %q", coll.Slug))
if err != nil {
writeListFilterKeyError(w, err)
return
}
if len(undeclared) > 0 {
w.Header().Set(undeclaredFilterKeysHeader, strings.Join(undeclared, ","))
}
result, err := s.listItemsPage(w, workspaceID, params)
if err != nil {
writeInternalError(w, err)
return
}
if result == nil {
result = []models.Item{}
}
s.enrichItemsWithParent(r, workspaceID, result, visibleIDs)
writeJSON(w, http.StatusOK, result)
}
// handleCreateItem creates a new item in a collection, validating fields against the schema.
func (s *Server) handleCreateItem(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
collSlug := chi.URLParam(r, "collSlug")
// Singular forms resolve against the workspace's real collections, so a
// template-defined or user-created collection has a shorthand too, not
// just the seven the client-side alias map knows (BUG-2578). Exact match
// still wins, so this cannot redirect a request that already worked.
coll, err := s.resolveItemCollectionSlug(workspaceID, collSlug)
if err != nil {
writeInternalError(w, err)
return
}
if coll == nil {
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
// Check edit permission (grant-aware for guests)
if !s.requireEditPermission(w, r, workspaceID, "", coll.ID) {
return
}
// Check collection visibility
visibleIDs, visErr := s.visibleCollectionIDs(r, workspaceID)
if visErr != nil {
writeInternalError(w, visErr)
return
}
if !isCollectionVisible(coll.ID, visibleIDs) {
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
var input models.ItemCreate
if err := decodeJSON(r, &input); err != nil {
// IDEA-1488 R2 codex P2: surface the domain-level errors from
// ItemCreate.UnmarshalJSON without the "invalid JSON: ..."
// wrapper from decodeJSON. PATCH and the view/collection
// handlers already do this; the POST path was missed in the
// original IDEA-1488 work. Mirrors handlers_items.go:641's
// PATCH-side handling.
if errors.Is(err, models.ErrInvalidFieldsType) {
writeError(w, http.StatusBadRequest, "bad_request", models.ErrInvalidFieldsType.Error())
return
}
if errors.Is(err, models.ErrInvalidTagsType) {
writeError(w, http.StatusBadRequest, "bad_request", models.ErrInvalidTagsType.Error())
return
}
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if !s.requireRawParentInWorkspace(w, workspaceID, input.ParentID) {
return
}
// BUG-2833 / BUG-2831. This used to be `input.Title == ""` inline, which is
// how create and update came to disagree: the rule was a literal in one
// handler, so the sibling handler on the same field never got it. It now
// runs through models.NormalizeItemTitle + models.ValidateItemTitle, the
// same pair store.CreateItem enforces authoritatively and
// handleUpdateItem/handleImportArtifact call.
//
// Two behaviour changes on THIS door, both deliberate:
// - whitespace-only titles are now refused. " " was legal on create and
// refused on artifact import; it slugifies to empty and lands on the
// store's "untitled" fallback, which Dave's ruling made defensive-only.
// - titles over MaxItemTitleRunes are refused, closing BUG-2831's
// dialect split at the door instead of at a Postgres btree error.
input.Title = models.NormalizeItemTitle(input.Title)
if msg := models.ValidateItemTitle(input.Title); msg != "" {
writeError(w, http.StatusBadRequest, "bad_request", msg)
return
}
// TASK-3585: an idempotency key, claimed before the write. A replay
// answers with the item the first request created, as it stands now.
// Claimed BEFORE the plan-limit check (retro CONVE-735 finding on #2065):
// a create that succeeded at the limit and lost its answer must replay,
// not be refused by the limit its own success reached. Every refusal
// below releases the key.
key, kerr := idempotencyKey(r, input.OpKey)
if kerr != nil {
writeError(w, http.StatusBadRequest, "bad_request", kerr.Error())
return
}
canon := input
canon.OpKey = ""
hold, ok := s.claimKey(w, r, workspaceID, idempotencyRouteCreate, coll.ID, canon, key, func(status int, stored string) {
var a createdAnswer
if json.Unmarshal([]byte(stored), &a) != nil {
writeInternalError(w, fmt.Errorf("stored idempotent answer does not decode"))
return
}
created, gerr := s.store.GetItem(a.ItemID)
if gerr != nil || created == nil {
writeError2(w, http.StatusGone, "idempotency_target_gone", "the item this key created no longer exists", map[string]interface{}{"item_id": a.ItemID})
return
}
if err := s.enrichItemForResponse(r, created, visibleIDs); err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, status, created)
})
if !ok {
return
}
defer s.release(hold)
if hold != nil {
input.AfterWriteTx = func(tx *sql.Tx, created *models.Item) error {
return s.completeTx(hold, http.StatusCreated, createdAnswer{ItemID: created.ID})(tx)
}
}
// Enforce item count limit (workspace-scoped)
if !s.enforcePlanLimit(w, r, workspaceID, "items_per_workspace") {
return
}
// Parse collection schema
var schema models.CollectionSchema
if err := models.UnmarshalItemFieldSchema([]byte(coll.Schema), &schema); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to parse collection schema")
return
}
// Parse and validate input fields
fieldMap := make(map[string]any)
if input.Fields != "" {
if err := models.DecodeJSONKeepingNumbers([]byte(input.Fields), &fieldMap); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid fields JSON")
return
}
}
// BUG-3163: create's `fields` refuses every reserved metadata key, the
// create-side twin of the field-patch gate (BUG-2627 / BUG-2696). A value
// arriving here from a `--field` / MCP `field` setter is a string no
// extractor can read. The check runs on the CALLER's map, before the typed
// convention member is lowered below, or that member would refuse itself.
// Artifact import shares createItemChecked but not this handler, and needs
// no gate: artifact.Decode fills Fields only from a fixed frontmatter key
// list (BUG-3163 checkpoint 2).
if bad := items.ReservedFieldKeysIn(fieldMap); len(bad) > 0 {
writeError(w, http.StatusBadRequest, "validation_error", reservedFieldCreateMessage(bad))
return
}
if input.Convention != nil {
normalized, cerr := models.ValidateConventionMetadata(input.Convention)
if cerr != nil {
writeError(w, http.StatusBadRequest, "validation_error", cerr.Error())
return
}
fieldMap[models.ItemFieldConvention] = normalized
}
// Extract parent from fields — it's managed via item_links, not stored in fields JSON.
// Accepts both "parent" and "plan" as the field key.
// Skip this if the schema actually defines a field with that key.
var parentValue string
for _, key := range []string{"parent", "plan"} {
if schemaHasField(schema, key) {
continue
}
if pv, ok := fieldMap[key]; ok && pv != nil {
if pvStr, ok := pv.(string); ok && pvStr != "" {
var resolvedParent *models.Item
if !isUUID(pvStr) {
resolvedParent, err = s.store.ResolveItem(workspaceID, pvStr)
if err != nil || resolvedParent == nil {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("parent %q not found", pvStr))
return
}
parentValue = resolvedParent.ID
} else {
resolvedParent, _ = s.store.GetItem(pvStr)
if resolvedParent == nil {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("parent %q not found", pvStr))
return
}
parentValue = resolvedParent.ID
}
// Ensure parent item belongs to this workspace and is visible
if resolvedParent.WorkspaceID != workspaceID {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("parent %q not found", pvStr))
return
}
if !s.requireItemVisible(w, r, workspaceID, resolvedParent) {
return
}
}
delete(fieldMap, key)
}
}
item, cerr := s.createItemChecked(r, workspaceID, coll, schema, input, fieldMap, parentValue, relationsRefuse)
if cerr != nil {
cerr.write(w, r)
return
}
// The visibility checked before the write, not a second, unchecked
// resolution after it (BUG-3334).
if err := s.enrichItemForResponse(r, item, visibleIDs); err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusCreated, item)
}
// itemCreateError carries an HTTP-status hint out of createItemChecked so
// callers (handleCreateItem and the artifact-import handler) can map the
// failure to a response without re-classifying it. Mirrors the
// importStatusError pattern in handlers_import_bundle.go.
type itemCreateError struct {
status int
code string
message string
// planLimit, when set, is the store's *PlanLimitError, and write answers
// with the structured plan-limit 403 instead of status/code/message.
planLimit error
}
func (e *itemCreateError) Error() string { return e.message }
// write answers the request with this error.
func (e *itemCreateError) write(w http.ResponseWriter, r *http.Request) {
if e.planLimit != nil && writeStorePlanLimitError(w, r, e.planLimit, "") {
return
}
writeError(w, e.status, e.code, e.message)
}
// createItemChecked is the shared item-create core: schema-field validation →
// workspace-unique-field precheck → persist → optional parent link → activity
// log + SSE event. (The webhook dispatch that used to close this list is gone
// — TASK-2714 moved webhook delivery to the outbox drain, fed by the row the
// persist step's own transaction writes.) Both handleCreateItem and the
// artifact-import handler call it so neither path can drop straight to
// store.CreateItem and skip validation, the uniqueness precheck, or the
// create side effects.
//
// The caller owns everything upstream of the validate step: resolving the
// collection, enforcing edit permission + collection visibility, decoding the
// request body, the advisory plan-limit pre-check (this helper passes the
// authoritative option to the store itself), and resolving + visibility-gating any
// parent (passed in as parentValue, an already-resolved item ID or "").
// fieldMap is the parsed-but-not-yet-validated structured fields; this helper
// validates it against schema, marshals the validated/defaulted result back
// into input.Fields, and stamps input.Source from the request auth context
// when the caller left it blank.
//
// Returns the created item (Ref/Slug populated by the store) or an
// *itemCreateError with a status hint.
// relationPosture says how a create door treats a relation value that does not
// resolve. Two callers, two answers, and the difference is Dave's ruling
// (day 57): refuse on write, carry on IMPORT.
//
// An artifact is not caller input in the sense the write doors mean. It was
// written elsewhere, possibly years ago and certainly before referent
// validation existed, and the person importing it did not choose its field
// values and cannot fix them from the import call. Refusing would break the
// import of exactly the artifacts most likely to carry junk — which is what
// Dave's ruling names: "explicitly allow import of the junk to avoid breaking
// import".
type relationPosture int
const (
// relationsRefuse — the caller typed these values, so an unresolvable one
// is their bug (create, update).
relationsRefuse relationPosture = iota
// relationsCarry — store what does not resolve and REPORT it. Not the
// migrate doors' carry, which drops what it cannot resolve: an import
// keeps the value, because the artifact is the record and discarding half
// of it silently is worse than importing something a client renders as
// unresolved (the read half of U2 does exactly that).
relationsCarry
)
func (s *Server) createItemChecked(r *http.Request, workspaceID string, coll *models.Collection, schema models.CollectionSchema, input models.ItemCreate, fieldMap map[string]any, parentValue string, posture relationPosture) (*models.Item, *itemCreateError) {
// BUG-3446: a library trigger/scope a blank workspace's system collection
// does not list yet is added before validation, and reported below. An
// artifact import (relationsCarry) keeps its own clear-and-warn handling
// of foreign select values, which runs before this and leaves nothing
// unlisted; the posture check makes that a rule rather than a sequence.
var optionsAdded map[string][]string
if posture == relationsRefuse {
if wanted := libraryOptionsWanted(coll, schema, fieldMap); len(wanted) > 0 {
// Validate against the widened schema first, on a copy (the
// pipeline injects defaults into its map), so a create that would
// be refused anyway never changes the collection (codex round 2).
if _, _, _, _, cerr := s.prepareCreateFields(r, workspaceID, coll, withSelectOptions(schema, wanted), maps.Clone(fieldMap), posture); cerr != nil {
return nil, cerr
}
var werr *itemCreateError
coll, schema, optionsAdded, werr = s.widenLibraryOptions(r, workspaceID, coll, schema, fieldMap)
if werr != nil {
return nil, werr
}
}
}
fieldMap, droppedDefaults, unresolved, undeclared, cerr := s.prepareCreateFields(r, workspaceID, coll, schema, fieldMap, posture)
if cerr != nil {
return nil, cerr
}
// Marshal validated/defaulted fields back
validatedFields, err := json.Marshal(fieldMap)
if err != nil {
return nil, &itemCreateError{status: http.StatusInternalServerError, code: "internal_error", message: "Failed to marshal validated fields"}
}
input.Fields = string(validatedFields)
// BUG-3812: prepareCreateFields checked uniqueness without a lock; the
// store checks again under the insert's lock.
input.UniqueCheck = &models.UniqueCheck{Schema: schema.Fields, Fields: fieldMap}
// Persist the source and the writer's kind from the request's auth
// context so the item row reflects which client created it, and who.
// Without this, items created via the CLI would persist as 'web' (the
// column default) and downstream signals like the dashboard's
// has_agent_activity flag (TASK-862) would never flip on. BUG-2542 added
// the created_by half.
//
// BUG-3372: both come from the request, never the body. The body used to
// win, so a caller could file an item as an agent's or a human's at will;
// no client sent either (the web, the CLI and both MCP doors leave them
// out), so nothing legitimate is lost.
input.CreatedBy, input.Source = actorFromRequest(r)
// PLAN-2348 U2: the create's version row names its user.
input.ActorUserID = currentUserID(r)
// BUG-3407: the schema bytes the fields were validated against (read at
// the top of this handler); the store re-checks under its lock if the
// schema moved since.
validatedSchema := coll.Schema
input.ValidatedSchema = &validatedSchema
item, err := s.store.CreateItem(workspaceID, coll.ID, input, s.workspaceLimitMintOpts()...)
if err != nil {
// BUG-2808: the cap, counted under the insert's lock, was reached after
// the caller's advisory pre-check admitted the request.
var ple *store.PlanLimitError
if errors.As(err, &ple) {
return nil, &itemCreateError{status: http.StatusForbidden, code: "plan_limit_exceeded", message: ple.Error(), planLimit: err}
}
// BUG-2833 / BUG-2831: the store's typed title refusal is a 400, not a
// 500. Today the handler's own check above catches every reachable
// case, so this arm is belt-and-braces — but its ABSENCE was a latent
// 500 that a mutation test surfaced, and the update path needs the same
// arm for a title that only becomes invalid under the lock. Both paths
// answer the same way because both go through the same helper.
// BUG-3407: the store refused fields the collection's schema stopped
// allowing after this handler validated them.
if v, ok := store.AsValidationError(err); ok {
return nil, &itemCreateError{status: http.StatusBadRequest, code: "validation_error", message: v.Reason}
}
// BUG-3812: a concurrent writer stored the value after the check above.
if held, ok := store.AsUniqueFieldHeldError(err); ok {
return nil, &itemCreateError{status: http.StatusConflict, code: "conflict", message: held.Error()}
}
var badTitle *store.InvalidItemTitleError
if errors.As(err, &badTitle) {
return nil, &itemCreateError{status: http.StatusBadRequest, code: "bad_request", message: badTitle.Reason}
}
// The NUL refusal, in this path's own envelope (codex round 1). Same
// classification as everywhere else, same sentence; only the shape
// differs, because createItemChecked returns its error rather than
// writing it.
if reason, ok := nulRefusalReason(err); ok {
return nil, &itemCreateError{status: http.StatusBadRequest, code: "bad_request", message: reason}
}
if strings.Contains(err.Error(), "UNIQUE constraint") || strings.Contains(err.Error(), "duplicate key") {
// Could be the slug-per-workspace constraint OR the playbook
// invocation_slug partial unique index (TASK-1378). Keep the
// message generic so it covers both — the application-layer
// pre-check (checkUniqueFields) catches the common case with a
// targeted error message; only a true concurrent race lands here.
return nil, &itemCreateError{status: http.StatusConflict, code: "conflict", message: "An item conflicts with an existing record (duplicate slug, title, or invocation slug)"}
}
slog.Error("createItemChecked: store.CreateItem failed", "error", err)
return nil, &itemCreateError{status: http.StatusInternalServerError, code: "internal_error", message: "An internal error occurred"}
}
// Create parent link if specified
if parentValue != "" {
actor, _ := actorFromRequest(r)
if _, err := s.store.SetParentLinkAs(workspaceID, item.ID, parentValue, actor, currentUserID(r)); err != nil {
return nil, &itemCreateError{status: http.StatusInternalServerError, code: "internal_error", message: fmt.Sprintf("item created but parent link failed: %v", err)}
}
// SetParentLink advances the source seq because it changes the
// local-first is_unparented bit. Re-read before publishing/returning
// so a create-with-parent event never advertises the stale create seq.
// The item and its parent link are already committed at this point, so
// a failed readback must NOT fail the create — that would return a 500
// for an item that exists and invite a duplicate-creating retry. Degrade
// to the item we already hold; the only cost is an event advertising the
// pre-link seq, which the next delta poll reconciles.
if fresh, rerr := s.store.GetItem(item.ID); rerr == nil && fresh != nil {
item = fresh
} else {
slog.Warn("createItemChecked: post-parent-link readback failed; returning pre-link snapshot",
"item_id", item.ID, "error", rerr)
}
}
actor, source := actorFromRequest(r)
actorNameForCreate := actorNameFromRequest(r)
if len(optionsAdded) > 0 {
// BUG-3446: the record of the schema change, where the owner reads
// it — the item's own timeline and the workspace activity feed.
meta, _ := json.Marshal(map[string]string{
"options_added": formatOptionsAdded(optionsAdded),
"options_added_collection": coll.Name,
})
s.logActivityWithMeta(workspaceID, item.ID, "created", r, stampSeqChain(string(meta), 0, item.Seq, ""))
} else {
s.logActivityWithMeta(workspaceID, item.ID, "created", r, stampSeqChain("", 0, item.Seq, ""))
}
s.publishItemEventWithName(sseItemCreated, workspaceID, item.ID, item.Title, coll.Slug, actor, actorNameForCreate, source, item.Seq)
// Assignment-at-creation (TASK-2533): unlike an update, a freshly
// created item has no "before" state to diff — LastMutation doesn't
// apply here — so this is a direct, unconditional check rather than
// a call to publishWatchNotifications.
if item.AssignedUserID != nil && *item.AssignedUserID != "" {
name := item.AssignedUserName
if name == "" {
name = *item.AssignedUserID
}
s.publishWatchNotification(watchevents.Notification{
WorkspaceID: workspaceID,
ItemID: item.ID,
CollectionID: item.CollectionID,
ItemRef: item.Ref,
Kind: watchevents.KindAssignment,
Actor: actor,
ActorName: actorNameForCreate,
Summary: fmt.Sprintf("assigned to %s on creation", name),
AssignedUserID: *item.AssignedUserID,
})
}
// Attach after the write succeeded: these are advisory notes about a
// stored item, not a reason to refuse one (BUG-2850).
if len(undeclared) > 0 || len(droppedDefaults) > 0 || len(unresolved) > 0 || len(optionsAdded) > 0 {
item.Warnings = &models.ItemWriteWarnings{
UndeclaredFields: undeclared,
DroppedFields: droppedDefaults,
UnresolvedRelations: unresolved,
OptionsAdded: optionsAdded,
}
}
return item, nil
}
// prepareCreateFields is createItemChecked's field pipeline, in its order:
// coerce to declared types, drop blank relations (refuse posture), apply and
// validate schema defaults, resolve relation referents, name undeclared keys,
// and the unique-field check. It returns the fields as a create would store
// them, plus the three warning lists. Extracted so the app installer's
// preview (TASK-3397) computes its digest over exactly what the import
// stores; createItemChecked is unchanged in behaviour.
func (s *Server) prepareCreateFields(r *http.Request, workspaceID string, coll *models.Collection, schema models.CollectionSchema, fieldMap map[string]any, posture relationPosture) (fields map[string]any, droppedDefaults, unresolved, undeclared []string, cerr *itemCreateError) {
return s.prepareCreateFieldsQ(s.store.Q(), r, workspaceID, coll, schema, fieldMap, posture)
}
// prepareCreateFieldsQ is prepareCreateFields with every read on q. The app
// installer runs it on the provisioning transaction (TASK-3397, U8b); every
// other caller passes the pool through prepareCreateFields.
func (s *Server) prepareCreateFieldsQ(q store.Queryer, r *http.Request, workspaceID string, coll *models.Collection, schema models.CollectionSchema, fieldMap map[string]any, posture relationPosture) (fields map[string]any, droppedDefaults, unresolved, undeclared []string, cerr *itemCreateError) {
// Coerce strings to their declared types before validating (BUG-2850).
fieldMap = items.CoerceFields(fieldMap, schema)
// BUG-3028: on an ordinary create every value is SUPPLIED, so a blank
// relation is removed before validation and a required one is refused as
// required. An artifact import (relationsCarry) carries values nobody typed
// in this request, so its blanks are removed after validation instead —
// the same carry-not-refuse posture it already has for unresolvable ones.
if posture == relationsRefuse {
items.DropBlankRelations(fieldMap, schema, nil)
}
// Snapshot before validation, which INJECTS schema defaults without
// type-checking them (codex round 7). The resolver below skips a
// non-string, so an injected `default: 42` on a relation field reached the
// blob unchallenged — the same hole the migrate doors had, by the same
// route, and closed with the same pass.
relBefore := store.RelationKeysPresent(schema, fieldMap)
// BUG-3079: a default that fails its own type check is DISCARDED and
// reported, not stored. The list joins the relation drops below in the one
// `warnings.dropped_fields` channel — they are the same event to a caller.
defaultDrops, err := items.ValidateFieldsWithDrops(fieldMap, schema)
if err != nil {
return nil, nil, nil, nil, &itemCreateError{status: http.StatusBadRequest, code: "validation_error", message: err.Error()}
}
items.DropBlankRelations(fieldMap, schema, nil)
// Referent validation for relation values (TASK-2878). AFTER the shape
// check, so "must be a string" and "names nothing" are never both reported
// for one value, and after coercion so the value is in its final form.
// The four steps live in one place — see resolveRelationsForWrite.
relRefusals, droppedDefaults, relErr := s.resolveRelationsForWriteQ(
q, r, workspaceID, workspaceRole(r), schema, fieldMap, relBefore, posture)
if relErr != nil {
return nil, nil, nil, nil, &itemCreateError{status: http.StatusInternalServerError, code: "internal_error", message: "Failed to resolve relation references"}
}
droppedDefaults = append(defaultDrops, droppedDefaults...)
if len(relRefusals) > 0 {
if posture == relationsRefuse {
return nil, nil, nil, nil, &itemCreateError{status: http.StatusBadRequest, code: "validation_error", message: relationIssuesMessage(relRefusals)}
}
// Carry: the values stay in fieldMap exactly as supplied — the
// resolver leaves what it cannot resolve untouched — and the write
// says which ones, so an import is never silently lossy.
for _, ri := range relRefusals {
unresolved = append(unresolved, ri.Key)
}
}
undeclared = items.UndeclaredFieldKeys(fieldMap, schema)
if err := s.checkUniqueFieldsQ(q, workspaceID, coll.ID, "", schema, fieldMap); err != nil {
return nil, nil, nil, nil, &itemCreateError{status: http.StatusConflict, code: "conflict", message: err.Error()}
}
return fieldMap, droppedDefaults, unresolved, undeclared, nil
}
// writeItemResolveError distinguishes a soft-deleted (archived) item from a
// genuinely-missing one for mutation handlers. An archived item still appears
// in include-archived list results and can be fetched read-only via GET, so a
// bare "Item not found" here misled callers into thinking the data was lost or
// corrupt (BUG-1791). Visibility is enforced exactly as the active path, so an
// archived item is never revealed to a caller who could not otherwise see it.
func (s *Server) writeItemResolveError(w http.ResponseWriter, r *http.Request, workspaceID, ref string) {
if archived, err := s.store.ResolveItemIncludeDeleted(workspaceID, ref); err == nil && archived != nil && archived.DeletedAt != nil {
if visible, verr := s.checkItemVisible(workspaceID, archived, currentUser(r), workspaceRole(r), isBearerAuth(r)); verr == nil && visible {
writeError(w, http.StatusConflict, "archived",
fmt.Sprintf("%q is archived. Fetch it read-only with GET, or restore it before editing.", ref))
return
}
}
writeError(w, http.StatusNotFound, "not_found", "Item not found")
}
// handleGetItem retrieves a single item by slug.
func (s *Server) handleGetItem(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItemIncludeDeleted(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
enrichVisIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
if err := s.enrichItemForResponse(r, item, enrichVisIDs); err != nil {
writeInternalError(w, err)
return
}
// The archived-source "moved to" pointer (PLAN-2357 / TASK-2359). Wired
// HERE and nowhere else — deliberately not inside enrichItemForResponse,
// which also runs on create/update/restore/move. Returns nil unless the
// item is archived, was genuinely MOVED (not merely copied), and the
// caller independently passes a read check on the destination ITEM.
// See item_moved_to.go for the disclosure rule.
item.MovedTo = s.movedToDestinations(r, item)
// Live execution lease (#1221). Read here rather than in the shared
// scan path: a lease is display/coordination state, absent on most
// items, and one point read on the single-item GET keeps the many
// item SELECT column lists untouched. Expired leases read as nil.
lease, err := s.store.GetItemLease(item.ID)
if err != nil {
writeInternalError(w, err)
return
}
item.Lease = lease
// Typed decisions (TASK-3117), same placement reasoning as the lease. A
// failure here is logged and the member omitted rather than failing the
// item read: decisions are advisory, and an item that cannot be fetched
// because a secondary read failed is a worse answer than one without them.
if decisions, derr := s.decisionRunner().ItemReadDecisions(item.ID); derr != nil {
slog.Warn("item read: decisions unavailable", "item_id", item.ID, "error", derr)
} else if len(decisions) > 0 {
item.Decisions = decisions
}
writeJSON(w, http.StatusOK, item)
}
// handleUpdateItem updates an existing item (fields, content, or both).
// writeItemRenameCascadeTooLarge answers a BUG-2804 cascade refusal as 413 and
// reports whether it handled the error.
//
// SHARED because handleUpdateItem reaches store.UpdateItemWithParentLink from
// THREE places — the plain path, the collab-snapshot callback, and the
// collab-edit callback — each with its own error block. The plain path was
// mapped first and the other two were missed, which is a population error, not
// a typo: the sweep that established "bulk and restore never set Title"
// checked other HANDLERS and never asked whether this handler had more than
// one error block (codex R2).
//
// The refusal is DETERMINISTIC and permanent, which is why the collab-edit
// path must treat it as final rather than letting it fall through to the
// direct write: falling through re-runs the whole cascade, re-reads every
// linking body and re-charges the projection, only to refuse identically. The
// caller waits twice for one answer.
func writeItemRenameCascadeTooLarge(w http.ResponseWriter, err error) bool {
var tooLarge *store.ItemRenameCascadeTooLargeError
if !errors.As(err, &tooLarge) {
return false
}
// Composed from TYPED fields, never by splicing err.Error() — every wrapper
// the call path added would otherwise be published to the client.
writeError(w, http.StatusRequestEntityTooLarge, "rename_cascade_too_large",
fmt.Sprintf("This rename would rewrite more linked content than the server will process in one "+
"operation: at least %d bytes, and the limit is %d. Reduce the number of items linking "+
"this title, or split the rename, and try again.", tooLarge.Processed, tooLarge.Max))
return true
}
// writeInvalidItemTitle maps the store's typed title refusal to a 400, and
// reports whether it did — same shape as writeItemRenameCascadeTooLarge above,
// and for the same reason: without it the refusal falls through to
// writeInternalError and lands as a 500, telling the caller the server broke
// and that retrying might work, when the request was understood, deliberately
// declined, and will be declined identically until the title changes.
//
// Shared by the THREE update error blocks (the plain path, the collab-snapshot
// callback and the collab-edit callback), which is where duplicating an arm has
// actually gone wrong twice — see the note on writeItemRenameCascadeTooLarge.
//
// The CREATE path does not use it and cannot: createItemChecked returns a typed
// *itemCreateError for its caller to write, rather than writing the response
// itself, so it maps the same store error with its own errors.As block. An
// earlier version of this comment claimed create shared this helper (codex
// round 5) — the rule they share is the 400, not the code.
//
// The Reason is taken from the TYPED field, never by splicing err.Error(): the
// call path wraps this error on the way up and those wrappers must not be
// published to the client.
// writeUniqueFieldHeld answers a write the store refused under its lock
// because a concurrent writer stored the unique_scope value first (BUG-3812):
// the same 409 and sentence as the check before the write.
func writeUniqueFieldHeld(w http.ResponseWriter, err error) bool {
held, ok := store.AsUniqueFieldHeldError(err)
if !ok {
return false
}
writeError(w, http.StatusConflict, "conflict", held.Error())
return true
}
func writeInvalidItemTitle(w http.ResponseWriter, err error) bool {
var bad *store.InvalidItemTitleError
if !errors.As(err, &bad) {
return false
}
writeError(w, http.StatusBadRequest, "bad_request", bad.Reason)
return true
}
// writeStoredStateUnreadable answers the Append* helpers' refusal (BUG-2627
// part 3), which since BUG-3056 runs in the store under the write lock rather
// than in the client: the item's stored implementation_notes / decision_log
// (or its whole fields blob) cannot be decoded, and appending would overwrite
// it. 409 with the same code the bulk door and both MCP transports use, since
// retrying refuses identically. Every UpdateItem error arm carries it, like
// writeInvalidItemTitle.
func writeStoredStateUnreadable(w http.ResponseWriter, err error) bool {
if !errors.Is(err, models.ErrStructuredFieldUnreadable) {
return false
}
writeError(w, http.StatusConflict, storedStateUnreadableCode, err.Error())
return true
}
// refuseUndeclaredFields answers the strict opt-in `refuse_undeclared_fields`
// on an item update (BUG-3156): when the caller set it and the write would
// store a key the item's collection does not declare, the write is refused
// with 400 validation_error and nothing is written. It refuses EXACTLY the
// keys warnings.undeclared_fields would otherwise have named on this write,
// so the two can never disagree about what "undeclared" means here: on a
// fields_patch write, the patched keys only; on a full `fields` write, the
// whole blob, stray stored keys included, as that warning always has.
//
// Without the flag nothing changes: the key is accepted and named in
// warnings.undeclared_fields (BUG-2850). A server that predates the flag
// ignores it as an unknown member, which is also that accept-and-warn, so a
// strict caller must not read a 200 as proof the key was declared unless the
// warning is absent.
//
// Its first callers are both transports of pad_item bulk-update, whose
// status/priority keys are the operation's, not the caller's, the same
// reasoning POST /items/bulk refuses them on (BUG-3154).
func refuseUndeclaredFields(w http.ResponseWriter, refuse bool, collectionSlug string, undeclared []string) bool {
if !refuse || len(undeclared) == 0 {
return false
}
writeError(w, http.StatusBadRequest, "validation_error",
fmt.Sprintf("collection %q has no %s field, and refuse_undeclared_fields is set, so nothing was written",
collectionSlug, quotedList(undeclared)))
return true
}
// quotedList renders keys as "a", "b" for a refusal message.
func quotedList(keys []string) string {
q := make([]string, len(keys))
for i, k := range keys {
q[i] = strconv.Quote(k)
}
return strings.Join(q, ", ")
}
func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
s.updateItem(w, r, nil)
}
// builtinItemUpdate is an update from a built-in's current library text
// (TASK-3462), run through the same door as any PATCH so it takes every
// guard a PATCH takes (the version token, pending collab edits, the applier,
// the uniqueness checks, activity and events). Only handleBuiltinUpdate
// builds one; nothing in a request body can.
type builtinItemUpdate struct {
// input replaces the decoded request body.
input models.ItemUpdate
// convention is the entry's typed convention metadata, lowered into the
// patch AFTER the caller-facing reserved-key refusal, as github_pr is: a
// value this server built from its own library, not one a caller sent.
convention *models.ItemConventionMetadata
// clearConvention removes the convention metadata (the library dropped
// it), through the same lowering.
clearConvention bool
// patch is set only by handlePatchItem (PLAN-3578): a content-anchored
// patch, whose body input.ContentTransform computes in the transaction.
patch *itemPatch
}
// updateItem is handleUpdateItem, or a built-in update when b is set.
func (s *Server) updateItem(w http.ResponseWriter, r *http.Request, b *builtinItemUpdate) {
// BUG-3080: set once the write has COMMITTED, read by the client_write
// release deferred below — the tab's mark moves only for a write that
// went through, and for every such write, even if the response then fails.
var clientWriteApplied bool
// Keys this write puts in the fields blob that the schema does not
// declare. Stored, not refused; reported on the response (BUG-2850).
var undeclaredFields []string
// Schema-declared keys this write discarded (TASK-2878) — see
// models.ItemWriteWarnings.DroppedFields.
var droppedDefaults []string
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
s.writeItemResolveError(w, r, workspaceID, itemSlug)
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
// Check edit permission (grant-aware for guests)
if !s.requireEditPermission(w, r, workspaceID, item.ID, item.CollectionID) {
return
}
// The response's visibility, resolved BEFORE the write (BUG-3334): an
// error here is a 500 with nothing written, where resolving it after
// would report a committed write as failed.
updateVisIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
// Test seam (BUG-2776): everything from the ResolveItem above to the
// store's locked re-read is the window a concurrent writer can commit in.
// Nil in production.
if s.afterItemPreRead != nil {
s.afterItemPreRead(item.ID)
}
var input models.ItemUpdate
if b != nil {
input = b.input
} else if err := decodeJSON(r, &input); err != nil {
// Surface the domain-level errors from ItemUpdate.UnmarshalJSON
// (BUG-1144) without the "invalid JSON: ..." wrapper from
// decodeJSON, so callers see a clean message naming the field.
if errors.Is(err, models.ErrInvalidFieldsType) {
writeError(w, http.StatusBadRequest, "bad_request", models.ErrInvalidFieldsType.Error())
return
}
if errors.Is(err, models.ErrInvalidTagsType) {
writeError(w, http.StatusBadRequest, "bad_request", models.ErrInvalidTagsType.Error())
return
}
if rep := (*models.RepeatedMemberError)(nil); errors.As(err, &rep) {
writeError(w, http.StatusBadRequest, "bad_request", rep.Error())
return
}
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if !s.requireRawParentInWorkspace(w, workspaceID, input.ParentID) {
return
}
// BUG-2833: the empty-title door. handleCreateItem has refused an empty
// title since it was written; this handler never had the check, so
// PATCH {"title": ""} was accepted and applied — one field, two handlers,
// two answers. BUG-2831 adds the length half: an unbounded title becomes an
// unbounded slug, which Postgres refuses at the UNIQUE(workspace_id, slug)
// btree while SQLite takes it.
//
// THIS CHECK REFUSES; IT DOES NOT DECIDE. It may answer 400 early, and it
// must not alter `input` — the grandfathering decision and the
// normalization both belong to store.UpdateItem, under the write lock.
//
// The reason is a real regression this handler caused when it did decide
// (caught by BUG-2776's own TOCTOU test): `item` here is read BEFORE any
// lock, so "the title equals the stored one" is a claim about a snapshot
// that a concurrent rename can invalidate. A rival renames the item inside
// the window; this request then sends the title IT last saw, which against
// the row AS COMMITTED is a genuine rename back. Deciding here dropped that
// write entirely and the timeline lost a rename that happened.
//
// Normalizing here is the same mistake in the other direction: it would
// destroy the store's raw-echo comparison, which is what lets a client echo
// a legacy title carrying edge whitespace without it reading as a rename.
//
// So the early refusal is skipped when the title matches the pre-lock read
// — a fast path that can only ever be wrong about whether to REFUSE, never
// about what to write, and the store refuses again anyway.
if input.Title != nil {
normalizedTitle := models.NormalizeItemTitle(*input.Title)
if normalizedTitle != item.Title && *input.Title != item.Title {
if msg := models.ValidateItemTitle(normalizedTitle); msg != "" {
writeError(w, http.StatusBadRequest, "bad_request", msg)
return
}
}
}
// TASK-2022: `fields` (full replace) and `fields_patch` (field-level
// merge) are mutually exclusive — they'd fight over the same column with
// contradictory semantics. Reject up front rather than silently pick one.
if input.Fields != nil && input.FieldsPatch != nil {
writeError(w, http.StatusBadRequest, "bad_request",
"cannot combine \"fields\" (full replace) and \"fields_patch\" (field-level merge) in one update")
return
}
// BUG-3056: a structured-entry append. Refused with a full `fields` replace,
// which would have to choose between the blob it carries and the row the
// append reads under the lock. The SERVER mints the entry's id, created_at
// and created_by — created_by from the same request signal that stamps this
// write's last_modified_by below, or an in-process caller's label (see
// WithStructuredEntryAuthor) — so a caller supplies only the text and
// cannot write an entry the extractor would not decode.
// TASK-3585: an idempotency key on this route covers appends only. A key
// on any other update would promise a replay this handler cannot give.
// The built-in and patch callers (b != nil) carry their own key.
var appendHold *idemHold
if b == nil {
key, kerr := idempotencyKey(r, input.OpKey)
if kerr != nil {
writeError(w, http.StatusBadRequest, "bad_request", kerr.Error())
return
}
if key != "" {
if !isAppendOnlyUpdate(input) {
writeError(w, http.StatusBadRequest, "bad_request",
"an idempotency key on an item update is accepted only with append_implementation_note and/or append_decision (and source)")
return
}
canon := input
canon.OpKey = ""
hold, ok := s.claimKey(w, r, workspaceID, idempotencyRouteAppend, item.ID, canon, key, func(status int, stored string) {
s.replayAppend(w, r, updateVisIDs, status, stored)
})
if !ok {
return
}
appendHold = hold
defer s.release(hold)
}
}
if input.AppendImplementationNote != nil || input.AppendDecision != nil {
if input.Fields != nil {
writeError(w, http.StatusBadRequest, "bad_request",
"cannot combine \"fields\" (full replace) with append_implementation_note or append_decision; use fields_patch for the other keys")
return
}
appendActor := structuredEntryAuthor(r)
mintedAt := time.Now().UTC().Format(time.RFC3339)
if a := input.AppendImplementationNote; a != nil {
input.ImplementationNoteToAppend = &models.ItemImplementationNote{
ID: models.NewStructuredEntryID("note"),
Summary: a.Summary,
Details: a.Details,
CreatedAt: mintedAt,
CreatedBy: appendActor,
}
}
if a := input.AppendDecision; a != nil {
input.DecisionToAppend = &models.ItemDecisionLogEntry{
ID: models.NewStructuredEntryID("decision"),
Decision: a.Decision,
Rationale: a.Rationale,
CreatedAt: mintedAt,
CreatedBy: appendActor,
}
}
}
if appendHold != nil {
answer := appendedAnswer{Note: input.ImplementationNoteToAppend, Decision: input.DecisionToAppend}
input.AfterWriteTx = func(tx *sql.Tx, updated *models.Item) error {
answer.ItemID = updated.ID
return s.completeTx(appendHold, http.StatusOK, answer)(tx)
}
}
// BUG-2627 part 2: `fields_patch` is the door every USER field-setter
// lowers into — `pad item update --field` (cmd_item.go), the MCP `field`
// param on the remote transport (dispatch_http_advanced.go), and the same
// param on stdio by way of that CLI. None of them may write the system
// metadata that HAS another writer, so the refusal sits here, once, rather
// than at each client. That now includes `github_pr` (BUG-2696): it was
// exempt as remote MCP's only writer, but this door stored it as an
// unreadable string on every transport, so it has a typed, validated
// member instead (input.GitHubPR, below).
//
// It sits with the mutual-exclusion check above rather than in the
// fields_patch block below because both are "reject an illegitimate patch
// SHAPE before doing any merge work", and neither needs the collection
// loaded to decide.
//
// Why refuse rather than drop the key: a `--field implementation_notes=...`
// write is not merely ineffective. It stores a value the extractor cannot
// decode, which makes the entries invisible on every surface AND trips the
// append guard (BUG-2627 part 3) so the legitimate `pad item note` path
// refuses on that item until someone repairs the row. Silently dropping it
// would leave the caller believing they had written history they had not.
//
// Scope: this closes the FIELD-PATCH door, which every user field-setter
// lowers into. The other two doors closed in BUG-3163: item CREATE refuses
// the keys in `fields` (convention activation uses a typed member), and a
// full `fields` blob may only CARRY stored reserved values unchanged
// (composeReservedCarryGuard, below in the full-fields block).
//
// The item's CURRENT fields go into the message because a remedy has to
// work in the state the caller is in: when the stored value is already
// undecodable, `pad item note` refuses too, and naming it anyway would
// route the caller in a circle (Codex round 1).
if bad := items.PatchRefusedFieldKeysIn(input.FieldsPatch); len(bad) > 0 {
writeError(w, http.StatusBadRequest, "validation_error", reservedFieldPatchMessage(bad, item.Fields))
return
}
// TASK-3462: a built-in update's convention metadata, lowered after the
// refusal above for the reason github_pr is lowered after it below.
if b != nil && (b.convention != nil || b.clearConvention) {
if input.FieldsPatch == nil {
input.FieldsPatch = map[string]any{}
}
if b.convention != nil {
input.FieldsPatch[models.ItemFieldConvention] = b.convention
} else {
input.FieldsPatch[models.ItemFieldConvention] = nil
}
}
// BUG-2696: the typed github_pr door. Checked AFTER the caller's own
// fields_patch has been refused any reserved key, then lowered into the
// patch, so the value takes the same merge-under-lock path every other
// patched key does. What this buys is VALIDITY, not sender identity: any
// caller may send it, but only a well-formed PR object gets in.
if input.GitHubPR != nil || input.ClearGitHubPR {
switch {
case input.GitHubPR != nil && input.ClearGitHubPR:
writeError(w, http.StatusBadRequest, "validation_error", "github_pr and clear_github_pr are mutually exclusive — send one")
return
case input.Fields != nil:
writeError(w, http.StatusBadRequest, "validation_error", "github_pr / clear_github_pr cannot be combined with a full `fields` write — send fields_patch, or no fields")
return
}
if input.GitHubPR != nil {
if err := models.ValidateGitHubPR(input.GitHubPR); err != nil {
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
}
if input.FieldsPatch == nil {
input.FieldsPatch = map[string]any{}
}
if input.GitHubPR != nil {
input.FieldsPatch[models.ItemFieldGitHubPR] = input.GitHubPR
} else {
// A nil value in fields_patch DELETES the key (store.mergeFieldsPatch).
input.FieldsPatch[models.ItemFieldGitHubPR] = nil
}
}
// TASK-2022: validate the optimistic-concurrency token's format at the
// boundary so a malformed value is a clean 400 rather than surfacing from
// the store as a generic 500. The store re-parses (guaranteed to succeed)
// and does the actual under-lock comparison.
if input.ExpectedUpdatedAt != "" {
if _, perr := time.Parse(time.RFC3339, input.ExpectedUpdatedAt); perr != nil {
writeError(w, http.StatusBadRequest, "bad_request",
"expected_updated_at must be an RFC3339 timestamp")
return
}
}
// BUG-3037: reject an expected_seq the server could never have issued. Every
// item INSERT stamps MAX(seq)+1 and the backfill migrations numbered from 1,
// so a live row's seq is >= 1; a value below that cannot match any row and
// would 409 forever, which reads to a caller as "someone keeps beating me"
// rather than "this token is malformed". Refusing only values the server
// never emits keeps this from rejecting our own output.
if input.ExpectedSeq != nil && *input.ExpectedSeq < 1 {
writeError(w, http.StatusBadRequest, "bad_request",
"expected_seq must be a positive integer — round-trip the `seq` you last read")
return
}
// IDEA-1494: precheck closure populated below when the patch
// includes a fields update AND --force is NOT set. Threads into
// the three UpdateItem call sites that follow. nil = no guard.
var openChildrenPrecheck func(tx *sql.Tx, existing *models.Item) error
// IDEA-1494 R4 P3 / BUG-2013: the parent-link change is captured
// here and applied INSIDE the same store transaction as the field
// write (see store.UpdateItemWithParentLink). A guard rejection on
// the status field rolls the whole tx back, and a failing link write
// rolls the field write back too — the caller only ever observes both
// writes or neither. Hoisted out of the `if input.Fields != nil`
// block so the parentLink directive below can read them.
var parentValue string
var parentProvided bool
// If fields are being updated, validate against schema
if input.Fields != nil {
coll, err := s.store.GetCollection(item.CollectionID)
if err != nil || coll == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection")
return
}
var schema models.CollectionSchema
if err := models.UnmarshalItemFieldSchema([]byte(coll.Schema), &schema); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to parse collection schema")
return
}
fieldMap := make(map[string]any)
if err := models.DecodeJSONKeepingNumbers([]byte(*input.Fields), &fieldMap); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid fields JSON")
return
}
// BUG-3163: the reserved keys this blob carries, taken before any pass
// below can touch the map. Checked against the row under the write lock
// by composeReservedCarryGuard, after the open-children precheck is set.
callerReserved := callerReservedFields(fieldMap)
// Extract parent from fields — it's managed via item_links, not stored in fields JSON.
// Accepts both "parent" and "plan" as the field key.
// (parentValue / parentProvided are declared at outer scope so
// the deferred link-write block below can read them — see
// IDEA-1494 R4 P3.) Shared with the fields_patch path via
// extractParentLink.
var extractOK bool
parentValue, parentProvided, extractOK = s.extractParentLink(w, r, workspaceID, schema, fieldMap)
if !extractOK {
return
}
if parentProvided && input.ParentID != nil {
// BUG-2594: a hierarchy write in fields ("parent"/"plan" — the
// canonical item_links surface) and a top-level "parent_id"
// (a raw column stamp no first-party client sends on update)
// in one request are two competing hierarchy writes. The old
// behavior applied BOTH — clearing the link while stamping
// the column, leaving silently inconsistent state. Refused,
// not silently resolved, per the clear_parent contract
// family's standing rule (v0.19).
writeError(w, http.StatusBadRequest, "validation_error",
`Request carries both a "parent"/"plan" key in fields and a top-level "parent_id" — two competing hierarchy writes. Send exactly one.`)
return
}
// Coerce strings to their declared types before validating (BUG-2850).
fieldMap = items.CoerceFields(fieldMap, schema)
// BUG-3028, by provenance: a blank relation this write SETS (the stored
// value is not already blank) is removed before validation, so a
// required one is refused; a legacy blank carried through unchanged is
// kept for validation and removed after it, landing as key-absent.
storedFields := map[string]any{}
if item.Fields != "" && item.Fields != "{}" {
_ = json.Unmarshal([]byte(item.Fields), &storedFields)
}
items.DropBlankRelations(fieldMap, schema, func(k string) bool {
return items.IsBlankRelationValue(storedFields[k])
})
// Snapshot before validation injects untyped schema defaults — see
// createItemChecked for the route (codex round 7).
relBefore := store.RelationKeysPresent(schema, fieldMap)
// BUG-3079 — see the create door for the rule.
defaultDrops, verr := items.ValidateFieldsWithDrops(fieldMap, schema)
if verr != nil {
writeError(w, http.StatusBadRequest, "validation_error", verr.Error())
return
}
// BUG-3407: the schema bytes these fields were validated against; the
// store re-checks them under its lock if the schema moved since.
validatedSchema := coll.Schema
input.ValidatedSchema = &validatedSchema
items.DropBlankRelations(fieldMap, schema, nil)
droppedDefaults = append(droppedDefaults, defaultDrops...)
// Referent validation for relation values (TASK-2878) — the same four
// steps the create door runs; see resolveRelationsForWrite.
relRefusals, writeDropped, relErr := s.resolveRelationsForWrite(
r, workspaceID, workspaceRole(r), schema, fieldMap, relBefore, relationsRefuse)
if relErr != nil {
writeInternalError(w, relErr)
return
}
if refuseRelationIssues(w, relRefusals) {
return
}
droppedDefaults = append(droppedDefaults, writeDropped...)
undeclaredFields = items.UndeclaredFieldKeys(fieldMap, schema)
if refuseUndeclaredFields(w, input.RefuseUndeclaredFields, coll.Slug, undeclaredFields) {
return
}
if err := s.checkUniqueFields(workspaceID, item.CollectionID, item.ID, schema, fieldMap); err != nil {
writeError(w, http.StatusConflict, "conflict", err.Error())
return
}
// BUG-3812: checked again inside the write, under its lock.
input.UniqueCheck = &models.UniqueCheck{Schema: schema.Fields, Fields: fieldMap}
// Auto-populate date fields on status changes
autoPopulateDates(fieldMap, item.Fields, schema)
// IDEA-1494 open-children guard. The actual check runs INSIDE
// the store transaction (see Store.UpdateItemWithPreCheck) so
// the children-list query and the parent's status write share
// a snapshot — closes the TOCTOU window flagged in Codex
// round 2 (P2). Here we just stage the inputs and the
// visibility filters; the closure runs later under the
// workspace seq lock + parent-children advisory lock.
if !input.Force {
var settings models.CollectionSettings
if coll.Settings != "" {
_ = json.Unmarshal([]byte(coll.Settings), &settings)
}
// Pre-compute visibility once. The guard's invariant check
// considers ALL children (data integrity); the visibility
// filter only affects which children appear in the 409
// payload — hidden ones are surfaced as a count.
//
// Fail CLOSED on visibility-lookup error (Codex round-3 P1):
// a swallowed error here would leave visIDs==nil, which
// openChildrenGuardChildVisible treats as "no restriction"
// — leaking metadata for hidden children. Surfacing the
// internal error blocks the update entirely rather than
// risk an information leak.
visIDs, visErr := s.visibleCollectionIDs(r, workspaceID)
if visErr != nil {
writeInternalError(w, visErr)
return
}
guestFull, guestGranted, gerr := s.guestResourceFilter(r, workspaceID)
if gerr != nil {
writeInternalError(w, gerr)
return
}
gctx := openChildrenGuardContext{
r: r,
workspaceID: workspaceID,
itemID: item.ID,
parentSchema: schema,
parentSettings: settings,
newFieldMap: fieldMap,
visibleCollectionIDs: visIDs,
guestFullCollIDs: guestFull,
guestGrantedItemIDs: guestGranted,
}
openChildrenPrecheck = func(tx *sql.Tx, existing *models.Item) error {
// Codex round-3 P2: classify the transition against
// the in-tx snapshot of the parent's fields, not the
// pre-tx capture. A concurrent writer can change the
// done-field value between handler-side read and lock
// acquisition; using `existing.Fields` (loaded by
// UpdateItemWithPreCheck after the tx began) avoids
// both false-fire and false-skip classifications.
txCtx := gctx
txCtx.currentFieldsJS = existing.Fields
details, derr := s.runOpenChildrenGuard(tx, txCtx)
if derr != nil {
return derr
}
if details != nil {
return &openChildrenGuardError{details: details}
}
return nil
}
}
// BUG-3163: a full blob may CARRY stored reserved metadata unchanged but
// may not change it, and omitting a stored key would delete it (the store
// replaces the blob), so omission is a change too (lead ruling on
// checkpoint 3). Composed into the precheck so the comparison is against
// the row as re-read under the write lock, on every ordering below.
openChildrenPrecheck = composeReservedCarryGuard(openChildrenPrecheck, callerReserved)
validatedFields, err := json.Marshal(fieldMap)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to marshal validated fields")
return
}
validated := string(validatedFields)
input.Fields = &validated
// IDEA-1494 R4 P3 / BUG-2013: parent-link mutations are neither
// run inline here nor deferred to a post-commit write. They are
// staged (parentValue / parentProvided, above) and handed to
// UpdateItemWithParentLink, which applies them INSIDE the same
// store transaction as the field write.
//
// History: the pre-IDEA-1494 code ran the link update inline
// (before the precheck), so a combined parent-change + status-flip
// PATCH could commit the link and then have the field write
// rejected by the guard. IDEA-1494 R4 P3 moved it to a post-commit
// write, which fixed that direction but left the OTHER partial-
// commit window: field write committed, then link write failed →
// 500 with half the patch applied. BUG-2013 closes it for good by
// folding the link write into the update tx (see the parentLink
// directive built below).
}
// TASK-2022 field-level merge path (IDEA-1480). `fields_patch` carries
// ONLY the keys the caller wants to change; the store shallow-merges them
// onto the item's current fields INSIDE the write transaction, closing
// the lost-write race that the full-blob read-modify-write suffers.
//
// Validation posture differs from the full-`fields` path on purpose: we
// validate ONLY the supplied keys (ValidatePartialFields — no
// required-field enforcement, no default injection), because a partial
// patch means "change exactly these, leave the rest alone." The
// open-children guard, unique-field check, and date auto-population all
// need to reason about the POST-merge state, so we compute a preview
// merge against the handler's current read for those — the authoritative
// merge still happens under the store's write lock.
if input.FieldsPatch != nil {
coll, err := s.store.GetCollection(item.CollectionID)
if err != nil || coll == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection")
return
}
var schema models.CollectionSchema
if err := models.UnmarshalItemFieldSchema([]byte(coll.Schema), &schema); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to parse collection schema")
return
}
// Work on a copy so the parent/plan extraction (which deletes keys)
// doesn't mutate the decoded request struct in surprising ways.
patchMap := make(map[string]any, len(input.FieldsPatch))
for k, v := range input.FieldsPatch {
patchMap[k] = v
}
// Parent/plan is a pseudo-field managed via item_links, not stored in
// the fields JSON — pull it out of the patch and stage the link write.
var extractOK bool
parentValue, parentProvided, extractOK = s.extractParentLink(w, r, workspaceID, schema, patchMap)
if !extractOK {
return
}
if parentProvided && input.ParentID != nil {
// BUG-2594: a hierarchy write in fields_patch ("parent"/"plan" — the
// canonical item_links surface) and a top-level "parent_id"
// (a raw column stamp no first-party client sends on update)
// in one request are two competing hierarchy writes. The old
// behavior applied BOTH — clearing the link while stamping
// the column, leaving silently inconsistent state. Refused,
// not silently resolved, per the clear_parent contract
// family's standing rule (v0.19).
writeError(w, http.StatusBadRequest, "validation_error",
`Request carries both a "parent"/"plan" key in fields_patch and a top-level "parent_id" — two competing hierarchy writes. Send exactly one.`)
return
}
// Coerce strings to their declared types before validating (BUG-2850).
patchMap = items.CoerceFields(patchMap, schema)
// BUG-3028: a patch key is a value this write SETS, so a blank relation
// becomes a delete, which ValidatePartialFields already refuses for a
// required field: "set it blank" and "delete it" now get one answer.
for _, k := range items.BlankRelationKeys(patchMap, schema) {
patchMap[k] = nil
}
// Only the PATCHED keys are reported. A stray key already on the item
// is not something this write introduced, and naming it every time
// anyone touches the item would train the reader to ignore the field.
//
// And only keys the patch STORES. A nil value in fields_patch is a
// DELETE — internal/store/items.go removes the key — so reporting it as
// an undeclared field would tell the caller a field was stored that the
// same request just removed (codex round 2). Filtered here rather than
// inside UndeclaredFieldKeys because nil means "store JSON null" on the
// full-fields path, where reporting it IS correct.
stored := make(map[string]any, len(patchMap))
for k, v := range patchMap {
if v == nil {
continue
}
stored[k] = v
}
undeclaredFields = items.UndeclaredFieldKeys(stored, schema)
if refuseUndeclaredFields(w, input.RefuseUndeclaredFields, coll.Slug, undeclaredFields) {
return
}
if err := items.ValidatePartialFields(patchMap, schema); err != nil {
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
// BUG-3407: the schema bytes this patch was validated against.
validatedSchema := coll.Schema
input.ValidatedSchema = &validatedSchema
// Referent validation for relation values (TASK-2878). Only the keys
// this patch carries are examined — the resolver skips absent keys —
// so a stray unresolvable value already stored on the item is not
// re-litigated by an update that does not touch it. That matches the
// undeclared-key rule directly above, and it is what keeps this from
// making every edit to a legacy item fail.
relIssues, relErr := s.resolveRelationReferents(r, workspaceID, schema, patchMap)
if relErr != nil {
writeInternalError(w, relErr)
return
}
if refuseRelationIssues(w, relIssues) {
return
}
if err := s.checkUniqueFields(workspaceID, item.CollectionID, item.ID, schema, patchMap); err != nil {
writeError(w, http.StatusConflict, "conflict", err.Error())
return
}
// BUG-3812: checked again inside the write, under its lock.
input.UniqueCheck = &models.UniqueCheck{Schema: schema.Fields, Fields: patchMap}
// Auto-populate start_date/end_date on a status change — but ONLY when
// the item's CURRENT stored value for that date is empty. A partial
// patch must not clobber a date the caller isn't touching (Codex
// round 2): running autoPopulateDates against the bare patchMap would
// see the date key absent and always inject today, overwriting an
// existing end_date on a status→done patch. So we run it against a
// merged view (current fields ∪ patch), then lift back only a date the
// helper genuinely FILLED (i.e. the current value was empty). Dates the
// caller set explicitly in the patch are left as-is; dates already set
// on the item are preserved untouched.
currentFields := map[string]any{}
if item.Fields != "" && item.Fields != "{}" {
_ = json.Unmarshal([]byte(item.Fields), &currentFields)
}
mergedForDates := map[string]any{}
for k, v := range currentFields {
mergedForDates[k] = v
}
for k, v := range patchMap {
if v == nil {
delete(mergedForDates, k)
continue
}
mergedForDates[k] = v
}
autoPopulateDates(mergedForDates, item.Fields, schema)
for _, dk := range []string{"start_date", "end_date"} {
if _, inPatch := patchMap[dk]; inPatch {
continue // caller set it explicitly — respect that
}
curVal, _ := currentFields[dk].(string)
if curVal != "" {
continue // already set on the item — never overwrite via auto-fill
}
if nv, ok := mergedForDates[dk]; ok {
if s, isStr := nv.(string); isStr && s != "" {
patchMap[dk] = nv // newly filled — ride along in the merge
}
}
}
// Open-children guard. The precheck runs IN-TX with the locked row,
// and — crucially for the patch path — computes the post-merge field
// map by applying patchMap onto the item's IN-TX current fields, not a
// stale pre-lock snapshot. Using a pre-computed full preview would
// present a done-field value even for a patch that never touches the
// done key, so a priority-only patch could false-fire the guard if the
// row's status changed concurrently between the handler read and lock
// acquisition (Codex review). Merging in-tx means the "to" done-field
// value equals the patch's value when set, else the current value —
// exactly the semantics the guard's trigger conditions expect.
if !input.Force {
var settings models.CollectionSettings
if coll.Settings != "" {
_ = json.Unmarshal([]byte(coll.Settings), &settings)
}
visIDs, visErr := s.visibleCollectionIDs(r, workspaceID)
if visErr != nil {
writeInternalError(w, visErr)
return
}
guestFull, guestGranted, gerr := s.guestResourceFilter(r, workspaceID)
if gerr != nil {
writeInternalError(w, gerr)
return
}
gctx := openChildrenGuardContext{
r: r,
workspaceID: workspaceID,
itemID: item.ID,
parentSchema: schema,
parentSettings: settings,
visibleCollectionIDs: visIDs,
guestFullCollIDs: guestFull,
guestGrantedItemIDs: guestGranted,
}
openChildrenPrecheck = func(tx *sql.Tx, existing *models.Item) error {
merged := make(map[string]any)
if existing.Fields != "" && existing.Fields != "{}" {
_ = json.Unmarshal([]byte(existing.Fields), &merged)
}
for k, v := range patchMap {
if v == nil {
delete(merged, k)
continue
}
merged[k] = v
}
txCtx := gctx
txCtx.newFieldMap = merged
txCtx.currentFieldsJS = existing.Fields
details, derr := s.runOpenChildrenGuard(tx, txCtx)
if derr != nil {
return derr
}
if details != nil {
return &openChildrenGuardError{details: details}
}
return nil
}
}
// Hand the validated, parent-stripped, date-augmented patch to the
// store, which merges it under the write lock.
input.FieldsPatch = patchMap
// BUG-3028: legacy blanks the patch does not touch are normalised by
// the store under its write lock (see ItemUpdate.BlankRelationKeys).
input.BlankRelationKeys = items.ScalarRelationKeys(schema)
}
// BUG-2013: build the atomic parent-link directive. When the PATCH
// changed the parent (parentProvided), this is threaded through every
// UpdateItemWithParentLink call site below so the link write shares
// the field write's transaction. parentValue=="" means "clear"; a
// non-empty value means "set to that item ID". Nil when the PATCH
// didn't touch the parent, giving the plain update path.
var parentLink *store.ParentLinkUpdate
if parentProvided {
linkActor, _ := actorFromRequest(r)
parentLink = &store.ParentLinkUpdate{
Provided: true,
ParentID: parentValue,
WorkspaceID: workspaceID,
CreatedBy: linkActor,
UserID: currentUserID(r), // BUG-3372
}
}
// Designated-applier routing (PLAN-1248 TASK-1257). When the
// PATCH body sets the content field AND an active collab room
// exists for this item, route the new markdown through a
// connected browser tab instead of writing items.content
// directly. The chosen tab does editor.commands.setContent,
// which the y-tiptap binding translates into Y.Doc updates that
// propagate via the regular sync path; items.content gets
// refreshed via the next 5s idle flush (TASK-1261).
//
// Without this hop, the connected browsers' Y.Doc state would
// silently overwrite items.content on the next flush and the
// CLI / API caller's update would be lost.
//
// Field-only PATCHes (input.Content == nil) skip this branch
// entirely; they continue straight to UpdateItem unchanged.
// fullWriteHandled is set when routeContentUpdate already ran the FULL
// UpdateItem — either through the direct path's PruneAndApply callback (content
// + title + fields + everything, inside the per-item lock) or through the
// applier path's row write. In either case we must not re-run UpdateItem below:
// we would duplicate the write and could produce two version-history rows.
// Instead the router hands back the post-write snapshot for the response.
// (Originally Codex round 9 on applyContentViaCollab's directWrite callback;
// that helper was retired with PLAN-2975's reorder and the invariant moved
// rather than went away.)
var fullWriteHandled bool
var fullWriteUpdated *models.Item
// BUG-2995: set when the content went through the designated applier, which
// writes the row WITHOUT content. The response is built from that row, so
// without this the 200 answers a content PATCH with the content from before the
// request — the caller's own verify read shows its write missing.
var contentAppliedPendingFlush bool
var appliedContent string
// `?source=collab-snapshot` opts out of the applier-routing path so
// a connected collab tab can flush its Y.Doc-derived markdown to
// items.content WITHOUT looping back through ApplyExternalContent
// (which would ask this same tab to apply, ack, and then strip
// input.Content — leaving items.content unchanged). The flag is
// trustworthy because the caller already has edit access (else the
// PATCH would 401/403); the bypass just skips a defensive
// re-routing that's only useful for EXTERNAL content updates.
// Per TASK-1260 / PLAN-1248.
collabSnapshot := r.URL.Query().Get("source") == "collab-snapshot"
// Stamp the per-version-row attribution so the Versions panel /
// `pad history` views can distinguish auto-flush snapshots from
// the user's explicit edits. Without this, every collab-driven
// 5s flush would land as Source="web" (UpdateItem's default
// when input.Source is empty — see store/items.go's UpdateItem),
// triggering the per-(actor, source) throttle and silently
// suppressing follow-up snapshots from a long editing session.
//
// IMPORTANT: this uses VersionSource (not Source) so the
// attribution lands on the version row only and does NOT mutate
// `items.source`. Mutating items.source would silently flip a
// CLI/MCP-created item out of `WorkspaceHasAgentActivity`'s
// `source IN ('cli', 'mcp')` filter just because the user
// happened to open the editor and trigger a 5s auto-flush. Per
// Codex round 3 of TASK-1267 [P2].
//
// **Force-override** when the query param is set, even if the
// body sent a different VersionSource. Otherwise a buggy or
// malicious client could send `?source=collab-snapshot` (which
// triggers the applier-bypass) with `"version_source":"cli"`
// in the body, sneaking a stale browser snapshot through the
// op-log GC watermark check (TASK-1309 round 5 [P1] keys on
// VersionSource to gate watermark advancement). The query
// param is the trustworthy server-side signal; the body
// VersionSource is client-attacker-controlled. Per Codex round
// 6 of TASK-1309 [P2].
// PLAN-2348 U2: the version row this update may write names its user.
// Internal-only field, so a body cannot set it.
input.ActorUserID = currentUserID(r)
if collabSnapshot {
input.VersionSource = "collab-snapshot"
} else {
// PLAN-2348 checkpoint 2, defect 3: every other write reached the
// version row with no source at all, so UpdateItem's "web" default
// labelled an agent's API body edit as a web edit. The request's own
// source is stamped here, on VersionSource for the reason above:
// items.source must not move on an update.
//
// BUG-3372: always the request's, never the body's version_source,
// which let a caller label its edit in History as any client's. No
// client sends one.
_, input.VersionSource = actorFromRequest(r)
}
// BUG-3372: items.source records where the item was CREATED; an update
// does not move it (see above), and a body-supplied source used to.
input.Source = ""
// TASK-2198 U4: "recovery" labels the server's own op-log recovery write
// in the version history. A client may not claim it. Nor "chatgpt", which
// only the ChatGPT door below sets (TASK-3321 U1b); a claim of it gets the
// request's own source instead. A collab-snapshot flush never reaches
// here with either: the branch above has already overwritten its label.
if input.VersionSource == models.VersionSourceRecovery {
input.VersionSource = ""
}
if input.VersionSource == models.VersionSourceChatGPT {
_, input.VersionSource = actorFromRequest(r)
}
// TASK-3321 U1b (ruling (b)): every content change through the ChatGPT
// catalog is saved as a version FIRST, bypassing the 1h throttle, so it
// can always be undone from History; that is what lets update_item be
// non-destructive. The body must then be the live one, so a tab's
// unflushed edits refuse the write (409 content_pending_flush) rather
// than be missing from the version and lost to an undo. Field-only
// updates write no version; the activity records them.
if isChatGPTSurface(r) && !collabSnapshot {
input.ChatGPTDoor = true
if input.Content != nil {
input.ForceVersion = true
input.VersionSource = models.VersionSourceChatGPT
input.RefusePendingEdits = true
}
}
// PLAN-3555 phase 1: count the unguarded update by door and kind, and mark
// an agent door's unguarded content write for the response. A built-in
// update is the server's own write and a collab-snapshot flush is the tab
// writing its document; neither is a caller's read-then-write.
var unguardedContentWrite bool
if b == nil && !collabSnapshot && updateIsUnguarded(&input) {
if kind := updateWriteKind(&input); kind != "" {
door := writeDoor(r)
if s.metrics != nil {
s.metrics.ContentWritesUnguardedTotal.WithLabelValues(door, kind).Inc()
}
unguardedContentWrite = kind == writeKindContent && door != writeDoorWeb
}
}
// BUG-2542: stamp the writer on single-item updates. Bulk ops already do
// this (handlers_items_bulk.go), but this path did not, so an agent's
// PATCH left last_modified_by at store.UpdateItem's "user" default and an
// item edited only by agents read as human-edited. BUG-3372: always the
// request's kind; a body value used to win.
input.LastModifiedBy, _ = actorFromRequest(r)
// BUG-3080: one tab's content writes are ordered by the counter it stamps
// on them. Held from here — past every validation, before the first write
// path — to the end of the handler, so the check, the write and the mark
// are one step for this (item, tab); see clientWriteMarks. The mark only
// moves when the write reaches the success answer below.
//
// CONTENT writes only: the stamp orders bodies, and a stamp on a write that
// carries none (a title or field PATCH) neither orders nor marks anything —
// otherwise it could raise the mark and refuse this tab's next real content
// write (codex round 1).
if input.ClientWrite != nil && input.Content != nil && s.clientWrites != nil {
cw := input.ClientWrite
if cw.Tab == "" || len(cw.Tab) > 64 || cw.N < 1 {
writeError(w, http.StatusBadRequest, "validation_error",
"client_write needs a tab id of 1-64 characters and a counter n of at least 1")
return
}
entry, applied := s.clientWrites.acquire(item.ID, cw.Tab)
defer func() { s.clientWrites.release(entry, cw.N, clientWriteApplied) }()
if cw.N < applied {
// Nothing is written. The tab's newer write owns the outcome, and
// the pane treats this answer as success, never retrying it.
if s.metrics != nil {
s.metrics.ContentWritesSupersededTotal.Inc()
}
slog.Info("client write superseded", "item_id", item.ID, "n", cw.N, "applied", applied)
writeError2(w, http.StatusConflict, "superseded_write",
"a newer write from this tab has already been applied; this one was not",
map[string]interface{}{"n": cw.N, "superseded_by": applied})
return
}
if s.afterClientWriteCheck != nil {
s.afterClientWriteCheck(item.ID, cw.N)
}
}
// Server-side gate: reject collab-snapshot PATCHes whose
// op_log_cursor is below MIN(item_yjs_updates.id). Such a
// cursor proves the flushing tab's Y.Doc was built on op-log
// rows that no longer exist (PruneAndApply, schema rebuild,
// dormant GC, or post-force_refresh races). The markdown the
// PATCH carries is, by definition, stale relative to the
// canonical content; accepting it would overwrite items.content
// from a known-bad source. The client-side guard
// (`forceRefreshInFlight`) covers the common case but an
// already-in-flight fetch can race a force_refresh and reach
// this handler with stale content. Per Codex round 10 [P1].
// Collab-snapshot path (TASK-1319). Run validation + UpdateItem
// under the per-item collab setup lock so concurrent prunes
// (PruneAndApply, schema rebuild, dormant GC) cannot land
// between the cursor check and the items.content write — that
// race would let a stale snapshot overwrite canonical content
// the prune just installed. Per Codex round 13 [P1].
if collabSnapshot && input.Content != nil && s.collab != nil {
err := s.collab.UnderItemLock(item.ID, func() error {
if input.OpLogCursor != nil {
minID, hasMin, merr := s.store.MinOpLogID(item.ID)
if merr != nil {
return merr
}
// Reject any incompatible cursor:
// - cursor > 0 against an empty op-log (entire
// log pruned). Flushing tab's Y.Doc was built
// on rows that no longer exist.
// - cursor < MIN against a non-empty op-log
// (includes cursor=0 + non-empty: a Y.Doc
// populated by replay binaries whose previous
// session never received the post-replay
// cursor frame). Per round 12 [P1].
var stale bool
switch {
case !hasMin && *input.OpLogCursor > 0:
stale = true
case hasMin && *input.OpLogCursor < minID:
stale = true
}
if stale {
slog.Info("collab-snapshot: rejecting PATCH; cursor incompatible with op-log",
"item_id", item.ID,
"cursor", *input.OpLogCursor,
"min_id", minID,
"has_min", hasMin,
)
return errStaleCollabSnapshot
}
}
// Restore-boundary gate (BUG-2264 Invariant B): reject a stale
// PRE-restore snapshot. A version restore that reconciled the live
// Y.Doc through the applier records a boundary = MAX(op-log.id) at
// restore time; a flush whose op_log_cursor is below it captured a
// Y.Doc predating the restore's applier op, so its markdown is the
// pre-restore content that would clobber the restore. The
// op_log_cursor frame is delivered AFTER the restore's binary update
// on the same conn, and every Y.Doc update re-arms the client flush,
// so a cursor >= boundary always reflects post-restore content — this
// rejects only genuinely stale flushes (a post-restore flush
// momentarily below the boundary is harmlessly deferred to the next
// flush). Runs OUTSIDE the cursor-present gate above and FAILS CLOSED
// on a missing cursor: a snapshot without a cursor can't prove it
// captured post-restore state, so once a boundary exists it must be
// rejected too (a nil cursor would otherwise sail straight past the
// fence into the write below).
// Read the restore boundary from the in-memory fast-path, falling back
// to the DURABLE items.restore_boundary_op_id column on a miss — so a
// pre-restore flush is fenced even after a server restart cleared the
// in-memory map (BUG-2264 residual #1). A durable read ERROR fails
// CLOSED (reject the flush): we can't prove the flush is post-restore,
// and rejecting is safe (the client reloads; no data is discarded).
boundary, ok := s.collab.RestoreBoundary(item.ID)
if !ok {
durable, hasDurable, derr := s.store.ItemRestoreBoundaryOpID(item.ID)
if derr != nil {
slog.Warn("collab-snapshot: durable restore-boundary read failed; failing closed",
"item_id", item.ID, "error", derr)
return errStaleCollabSnapshot
}
if hasDurable {
boundary, ok = durable, true
}
}
if ok {
if input.OpLogCursor == nil || *input.OpLogCursor < boundary {
cur := int64(-1)
if input.OpLogCursor != nil {
cur = *input.OpLogCursor
}
slog.Info("collab-snapshot: rejecting PATCH; cursor below/absent vs restore boundary",
"item_id", item.ID,
"cursor", cur,
"has_cursor", input.OpLogCursor != nil,
"restore_boundary", boundary,
)
return errStaleCollabSnapshot
}
}
updated, uerr := s.store.UpdateItemWithParentLink(item.ID, input, openChildrenPrecheck, parentLink)
if uerr != nil {
return uerr
}
fullWriteHandled = true
fullWriteUpdated = updated
return nil
})
if err != nil {
if details, ok := asOpenChildrenGuardError(err); ok {
writeOpenChildrenError(w, itemRefOrSlug(*item), details)
return
}
if writeReservedFieldsCarryError(w, err) {
return
}
if errors.Is(err, errStaleCollabSnapshot) {
writeError(w, http.StatusConflict, "stale_collab_snapshot",
"This editor's view is out of sync with the server; please reload.")
return
}
if writeSnapshotBehindContentError(w, item.ID, err) {
return
}
if conflict, ok := asUpdateConflictError(err); ok {
writeUpdateConflictError(w, itemRefOrSlug(*item), conflict)
return
}
// BUG-3407 (codex r3): the collab-snapshot path's own arm.
if v, ok := store.AsValidationError(err); ok {
writeError(w, http.StatusBadRequest, "validation_error", v.Reason)
return
}
if writeUniqueFieldHeld(w, err) {
return
}
// BUG-2804 / codex R2: without this the cascade refusal reaches the
// client as a 500 from this path only, while the plain path answers
// 413 for the identical store error.
if writeItemRenameCascadeTooLarge(w, err) {
return
}
// BUG-2833 / codex round 1, P2 — the identical omission, one
// release later. This block mirrors the plain path's arms, and a
// PATCH carrying both content and a title reaches the store through
// it, so a title the store refuses under the lock would answer 500
// here while the plain path answers 400.
if writeInvalidItemTitle(w, err) {
return
}
if writeStoredStateUnreadable(w, err) {
return
}
// Mirror the main UpdateItem path: map UNIQUE constraint /
// duplicate key races (e.g. concurrent edits both racing the
// invocation_slug partial unique index) to 409 conflict so
// they don't surface as misleading 500s.
if strings.Contains(err.Error(), "UNIQUE constraint") || strings.Contains(err.Error(), "duplicate key") {
writeError(w, http.StatusConflict, "conflict", "An item conflicts with an existing record (duplicate slug, title, or invocation slug)")
return
}
writeInternalError(w, err)
return
}
} else if collabSnapshot && input.Content != nil && input.OpLogCursor != nil {
// Fallback for the collab-disabled build (s.collab == nil): no
// concurrent prunes happen, so the un-locked check is
// race-free here. The cursor gate semantics are unchanged.
minID, hasMin, merr := s.store.MinOpLogID(item.ID)
if merr != nil {
writeInternalError(w, merr)
return
}
var stale bool
switch {
case !hasMin && *input.OpLogCursor > 0:
stale = true
case hasMin && *input.OpLogCursor < minID:
stale = true
}
if stale {
slog.Info("collab-snapshot: rejecting PATCH; cursor incompatible with op-log",
"item_id", item.ID,
"cursor", *input.OpLogCursor,
"min_id", minID,
"has_min", hasMin,
)
writeError(w, http.StatusConflict, "stale_collab_snapshot",
"This editor's view is out of sync with the server; please reload.")
return
}
}
// BUG-3133: a content write that carries a version token asserted "the body
// as of that version", and unflushed collaborative edits are not in the row
// the token guards. Refuse it while any are pending, unless the caller says
// it means to replace them. Composed into the precheck so every ordering
// below — direct, applier, collab-disabled — inherits it from here. The
// collab-snapshot flush is exempt: it is the tab writing those edits.
// refuse_pending_edits asks for the same refusal with no token (BUG-3230
// U0): the pane's raw saves cannot carry one.
//
// BUG-3244: rows a schema rebuild set aside refuse EVERY such write, token
// or not, and overwrite_pending_edits discards them on every path, not just
// the direct one: no tab holds them, so the applier replacing the live
// document does not touch them.
var discardedSetAside int
if (input.Content != nil || input.ContentTransform != nil) && !collabSnapshot {
if input.OverwritePendingEdits {
openChildrenPrecheck = composeSetAsideDiscard(s, item.ID, openChildrenPrecheck, &discardedSetAside)
} else {
tokenGuarded := input.ExpectedSeq != nil || input.ExpectedUpdatedAt != "" || input.RefusePendingEdits
openChildrenPrecheck = composePendingContentGuard(s, item.ID, tokenGuarded, openChildrenPrecheck)
}
}
// BUG-3133 D: how many unflushed edit rows the direct path's prune deleted.
var prunedPendingEdits int
// PLAN-3578: a patch takes the direct path only; an open editor refuses it.
if input.ContentTransform != nil && s.collab != nil && !collabSnapshot {
route, updated := s.patchRoute(w, r, item, &input, openChildrenPrecheck, parentLink, &prunedPendingEdits, b.patch)
if route == contentRouteHandled {
return
}
fullWriteHandled = true
fullWriteUpdated = updated
// The room path's body is in the live document; the row catches up on
// the tab's next flush, as for any applier write.
contentAppliedPendingFlush = route == contentRouteApplierWrote
}
if input.Content != nil && s.collab != nil && !collabSnapshot {
// PLAN-2975: WRITE FIRST, APPLY SECOND on the applier path.
//
// Before this, the content half was pushed into the live Y.Doc by
// ApplyExternalContent and the row write ran afterwards, so any of the
// four typed refusals below answered 4xx while the content was already
// in the collaborative document and would reach items.content on the
// next ?source=collab-snapshot flush (BUG-2840 half A). A refused PATCH
// must not change the item.
//
// The ordering is only possible because the handler can now learn which
// path it is on WITHOUT applying (TASK-2987's HasElectableApplier);
// previously the only way to discover the applier path was to take it.
contentToApply := *input.Content
route, updated := s.routeContentUpdate(w, r, item, &input, openChildrenPrecheck, parentLink, contentToApply, &prunedPendingEdits)
switch route {
case contentRouteHandled:
// The refusal or the settling answer has already been written.
return
case contentRouteApplierWrote:
// The row write committed and the apply succeeded: content is in the
// document and reaches items.content on the next flush.
fullWriteHandled = true
fullWriteUpdated = updated
input.Content = nil
contentAppliedPendingFlush = true
appliedContent = contentToApply
case contentRouteDirectWrote:
// PruneAndApply ran the full write (content included) under the
// per-item lock, for a room with no live writer at all.
fullWriteHandled = true
fullWriteUpdated = updated
input.Content = nil
}
// No default and no fall-through arm: routeContentUpdate answers every
// failure itself and returns nothing for this caller to answer again
// (BUG-2994).
}
var updated *models.Item
if fullWriteHandled {
updated = fullWriteUpdated
} else {
updated, err = s.store.UpdateItemWithParentLink(item.ID, input, openChildrenPrecheck, parentLink)
}
if err != nil {
// PLAN-3578: an anchor that no longer matches, when collab is off.
if s.writePatchRefusal(w, itemRefOrSlug(*item), err) {
return
}
// IDEA-1494 R2: surface the open-children guard rejection as
// the structured 409 BEFORE the UNIQUE-constraint / generic
// internal-error paths can swallow it. Same shape the
// in-handler write produced pre-R2, but now correctly fires
// when the guard ran inside the store tx.
if details, ok := asOpenChildrenGuardError(err); ok {
writeOpenChildrenError(w, itemRefOrSlug(*item), details)
return
}
// BUG-3163: the full-fields reserved-metadata carry check.
if writeReservedFieldsCarryError(w, err) {
return
}
// TASK-2022: optimistic-concurrency conflict → structured 409.
if conflict, ok := asUpdateConflictError(err); ok {
writeUpdateConflictError(w, itemRefOrSlug(*item), conflict)
return
}
if writeSnapshotBehindContentError(w, item.ID, err) {
return
}
if pending, ok := store.AsContentPendingFlushError(err); ok {
writeContentPendingFlushError(w, itemRefOrSlug(*item), pending, input.ChatGPTDoor)
return
}
// BUG-3407: fields the collection's schema stopped allowing after this
// handler validated them.
if v, ok := store.AsValidationError(err); ok {
writeError(w, http.StatusBadRequest, "validation_error", v.Reason)
return
}
if writeUniqueFieldHeld(w, err) {
return
}
// BUG-2804: the item rename cascade refuses renames that would process
// more linking content than MaxItemRenameCascadeBytes. Without this arm
// the refusal falls through to writeInternalError and lands as a 500 —
// which tells the caller the server broke and that retrying might work,
// when in fact the request was understood, deliberately declined, and
// will be declined identically until the workspace's content changes.
//
// Mirrors the document twin at handlers_documents.go:202, including its
// rule about composition: the sentence is built from the error's TYPED
// fields and never by splicing err.Error(), because every wrapper the
// call path added on the way up would otherwise be published to the
// client.
if writeItemRenameCascadeTooLarge(w, err) {
return
}
// BUG-2833 / BUG-2831: the store's authoritative title check. The
// pre-lock check above catches this for ordinary callers; this arm is
// what a title that only became invalid under the lock lands on, and it
// must be a 400 rather than writeInternalError's 500 — the request was
// understood and declined, and retrying it unchanged will be declined
// identically.
if writeInvalidItemTitle(w, err) {
return
}
if writeStoredStateUnreadable(w, err) {
return
}
// Map UNIQUE constraint races (e.g. concurrent updates that both
// pass checkUniqueFields and then both hit the partial unique
// index on invocation_slug) to 409 conflict, matching the create
// path. Without this, a benign race surfaces as a misleading 500.
if strings.Contains(err.Error(), "UNIQUE constraint") || strings.Contains(err.Error(), "duplicate key") {
writeError(w, http.StatusConflict, "conflict", "An item conflicts with an existing record (duplicate slug, title, or invocation slug)")
return
}
writeInternalError(w, err)
return
}
if updated == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
// BUG-3080: the write has COMMITTED on every path that reaches here, so
// this tab's mark moves now — not at the response. Enrichment below can
// still fail with a 500, and a mark left behind then would let this tab's
// older write pass its check and overwrite what was just stored (codex
// round 2).
clientWriteApplied = true
// BUG-2013: the parent-link mutation is no longer a separate
// post-commit write here — it ran INSIDE UpdateItemWithParentLink's
// transaction above (via the parentLink directive). A cycle or DB
// error on the link write now rolls the field write back and surfaces
// through the shared error handling above, so there is no remaining
// partial-commit window and nothing to do in this post-commit stage.
// Build rich metadata describing what changed.
//
// BUG-2776: the before-image is the store's IN-TRANSACTION pre-write
// view, not this handler's `item`. `item` was read at the top of this
// function — before the permission checks, before the store's locks —
// so anything a concurrent writer committed in between shows up in the
// difference and is attributed, with this request's actor and agent
// name, to whoever sent this PATCH. The store already re-reads the row
// under its own locks (that snapshot is what it diffs for the status and
// assignment signals); PreUpdate is that same snapshot, handed back.
//
// It also makes the comparisons committed-vs-committed: `updated` is
// what this transaction wrote, `before` is what it wrote over, so the
// change list describes the transaction rather than the request's
// intent. That is why the title arm below compares updated.Title
// instead of input.Title.
// No pre-image means no defensible change list. The obvious fallback —
// diff the handler's own stale `item` — is EXACTLY the defect, so a
// future update path that returns an item without a pre-image must lose
// the change list rather than quietly resume inventing wrong ones: an
// entry that says nothing is recoverable, an entry that names the wrong
// author is not, and this bug exists because the second failure is
// invisible. The activity row itself is still written; only its
// human-readable detail is dropped, and the warning names the invariant
// so the cause is one grep away (codex round 3).
before := updated.PreUpdate
if before == nil {
slog.Warn("item update returned no pre-image; writing the activity without a change list",
"item_id", updated.ID, "workspace_id", workspaceID,
"invariant", "models.Item.PreUpdate is set by UpdateItemWithParentLink on every successful update")
}
var meta string
if before != nil {
if changes := diffFields(before.Fields, updated.Fields); changes != "" {
meta = fmt.Sprintf(`{"changes":%q}`, changes)
}
}
// appendChange, not the old "only when meta is empty" form: a PATCH that
// renamed the item AND changed a field used to report the field change
// and silently drop the rename. Adjacent to the lines this fix rewrites,
// so folded in rather than filed.
if before != nil {
if updated.Title != before.Title {
meta = appendChange(meta, fmt.Sprintf("title: %s → %s", before.Title, updated.Title))
}
// Track role and assignment changes
if updated.AgentRoleSlug != before.AgentRoleSlug {
roleChange := fmt.Sprintf("role: %s → %s", valueOrEmpty(before.AgentRoleName), valueOrEmpty(updated.AgentRoleName))
meta = appendChange(meta, roleChange)
}
if updated.AssignedUserName != before.AssignedUserName {
assignChange := fmt.Sprintf("assigned: %s → %s", valueOrEmpty(before.AssignedUserName), valueOrEmpty(updated.AssignedUserName))
meta = appendChange(meta, assignChange)
}
}
// PLAN-2348 U2: the body changed and the per-actor throttle wrote no
// version row, so this activity is the edit's only record. The History
// tab (U3) renders the marker as "edited the description" and, when it is
// the newest body change, diffs the last saved version against the
// current body.
if updated.BodyEditedWithoutVersion {
meta = withActivityMetaKey(meta, "body_edited", "true")
}
// PLAN-3578: the seq chain a rebased patch reports its intervening
// writes from. Without a pre-image the link is unknown, so the entry
// carries no stamp and the chain reads broken there.
// Only a write that moved the seq joins the chain: an entry stamped
// prev == seq would shadow the real write at that seq.
if before != nil && updated.Seq != before.Seq {
meta = stampSeqChain(meta, before.Seq, updated.Seq, updated.CreatedVersionID)
}
if b != nil && b.patch != nil {
meta = b.patch.activityMeta(meta)
}
actor, source := actorFromRequest(r)
// The error is CHECKED here (BUG-2779). The activity write is
// best-effort — a failed audit entry must not fail the user's update,
// which is why this logs rather than returns — but the failure has to be
// VISIBLE, because the comment below links to `activityID` and a silent
// failure means a user's comment quietly loses its link on a 200. The two
// comment handlers already guard the same way; this brings the third
// caller in line with them.
//
// The id is NOT re-zeroed here. The store's contract now guarantees an
// empty id beside a non-nil error, and re-zeroing it at the call site
// would be a second mechanism for a window the first already covers —
// which hides which one is load-bearing, and would keep passing if the
// store's contract regressed. The log is the part nothing else provides.
activityID, activityErr := s.logActivityWithMetaReturningID(workspaceID, updated.ID, "updated", r, meta)
if activityErr != nil {
// UNTESTED, and the reason is worth stating rather than leaving for
// the next reader to rediscover: asserting this line needs an
// activity write that FAILS while the item update SUCCEEDS, which no
// existing seam produces — the store-level tests induce failure by
// closing the database, which would fail the update too. A seam whose
// only purpose is to let a test read a log line is not worth a field
// on Server, so this is held by the store's contract plus review
// rather than by an assertion. A mutation deleting it survives.
slog.Warn("item update activity not recorded; a comment on this update will not be linked",
"item_id", updated.ID, "workspace_id", workspaceID, "error", activityErr)
}
actorNameForUpdate := actorNameFromRequest(r)
s.publishItemEventWithName(sseItemUpdated, workspaceID, updated.ID, updated.Title, updated.CollectionSlug, actor, actorNameForUpdate, source, updated.Seq)
s.publishWatchNotifications(workspaceID, updated, actor, actorNameForUpdate)
// If a comment was attached to this update (e.g. explaining a status change),
// create a comment linked to the activity entry. The comment reads its
// agent name through that link (TASK-2760); between the activity write
// above and the link below the row is still unlinked, so a concurrent
// update under the same user can debounce-merge into it and bump the
// created_at the comment will then sit against. It can no longer overlay a
// DIFFERENT agent's stamp: since BUG-2763 the debounce refuses to coalesce
// across writer identities, so whatever merges here declares the same name
// this comment will carry. Same window BUG-2716 closed for the two
// "commented" sites; here it stays open, deliberately NOT
// CreateCommentWithActivity (lead-ruled): this "updated" activity records
// a write that has already committed and must survive a comment failure,
// so the two-step order here is the correct one.
if input.Comment != nil && strings.TrimSpace(*input.Comment) != "" {
commentInput := models.CommentCreate{
Body: strings.TrimSpace(*input.Comment),
ActivityID: activityID,
}
if u := currentUser(r); u != nil {
commentInput.Author = u.Name
}
commentInput.CreatedBy = actor
commentInput.Source = source
comment, cerr := s.store.CreateComment(workspaceID, updated.ID, currentUserID(r), commentInput)
if cerr != nil {
slog.Warn("failed to create comment on item update", "item_id", updated.ID, "error", cerr)
}
if cerr == nil && comment != nil {
s.publishCommentEvent(sseCommentCreated, workspaceID, updated.ID, comment.ID, updated.Title, updated.CollectionSlug, actor, source)
// item.updated_with_comment is RETIRED (SPEC-3 v1.2, Dave's
// ruling): a hand-rolled composite with no flood argument,
// which folds into the item event this update emitted plus
// comment.created.
//
// PRECISELY, because "item.updated + comment.created" would
// overstate it twice (codex round 1): the item half is whichever
// slice actually moved under the disjoint-delta rule — a
// status-only update emits item.status_changed, not item.updated
// — and the two events come from two SEPARATE transactions, since
// CreateComment runs after the update has committed. What holds
// is that each is written transactionally with ITS OWN mutation,
// which is all the composite was ever standing in for.
//
// No deprecation window: webhooks are CLI-registered only today
// (no UI surface) and there were no known consumers at ruling
// time.
// TASK-2533: this comment is created via a DIFFERENT code
// path than handleCreateComment (store.CreateComment called
// directly, not through POST .../comments) — a bypass Rider
// 1's producer audit would otherwise miss. Same
// kind=comment notification as the standalone endpoint.
s.publishWatchNotification(watchevents.Notification{
WorkspaceID: workspaceID,
ItemID: updated.ID,
CollectionID: updated.CollectionID,
ItemRef: updated.Ref,
Kind: watchevents.KindComment,
Actor: actor,
ActorName: actorNameForUpdate,
Summary: truncateForSummary(comment.Body, 120),
})
}
}
if err := s.enrichItemForResponse(r, updated, updateVisIDs); err != nil {
writeInternalError(w, err)
return
}
// BUG-2995: on the applier path `updated` is the row as it stood BEFORE this
// request, because applierFirstWrite nils Content so the markdown can travel
// through the live Y.Doc instead. Answering with that row's content tells a
// caller its write was dropped.
//
// The response carries the content as SENT, and the marker below is what makes
// that honest: the echoed value is a true statement about what the applier
// received, and not a claim about what the row holds. The marker describes this
// WRITE — that the content went to the document rather than the row — rather
// than reporting the row's state at response time, which would need a lock
// across the apply and a re-read, neither of which this path has. Echoing
// without the marker would be the overclaim — the stored form MAY differ from
// the sent form (measured on BUG-2995's trail; markdown already in the editor's
// preferred form survives unchanged, anything else normalises), so a caller
// comparing this value to a later read could find a difference it had no way to
// account for.
if contentAppliedPendingFlush {
updated.Content = appliedContent
}
// BUG-3056: echo the appended entries, with the ids the server minted.
if input.ImplementationNoteToAppend != nil || input.DecisionToAppend != nil {
updated.Appended = &models.ItemAppendedEntries{
ImplementationNote: input.ImplementationNoteToAppend,
Decision: input.DecisionToAppend,
}
}
// Set-aside rows an override discarded are edits the write deleted, like the
// prune's, so they are reported in the same count (BUG-3244).
prunedPendingEdits += discardedSetAside
// Advisory, post-write, same as create (BUG-2850).
if len(undeclaredFields) > 0 || len(droppedDefaults) > 0 || contentAppliedPendingFlush || prunedPendingEdits > 0 || unguardedContentWrite {
warnings := &models.ItemWriteWarnings{
UndeclaredFields: undeclaredFields,
DroppedFields: droppedDefaults,
PrunedPendingEdits: prunedPendingEdits,
UnguardedContentWrite: unguardedContentWrite,
}
if contentAppliedPendingFlush {
warnings.ContentOutcome = contentOutcomeAppliedPendingFlush
}
updated.Warnings = warnings
}
if b != nil && b.patch != nil {
s.writePatchResponse(w, updated, b.patch, contentAppliedPendingFlush)
return
}
writeJSON(w, http.StatusOK, updated)
}
// handleDeleteItem archives (soft-deletes) an item.
func (s *Server) handleDeleteItem(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
s.writeItemResolveError(w, r, workspaceID, itemSlug)
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
// Check edit permission (grant-aware for guests)
if !s.requireEditPermission(w, r, workspaceID, item.ID, item.CollectionID) {
return
}
if err := s.store.DeleteItem(item.ID); err != nil {
writeInternalError(w, err)
return
}
// TASK-3365: an archived item leaves every view and its collab room;
// connections on this workspace re-check now.
s.invalidateWorkspaceAccess(item.WorkspaceID)
// Fetch the post-delete row so the SSE event carries the new
// `seq` (DeleteItem bumps it). Falls back to 0 if the row is
// missing (race with hard-delete or a Postgres timing quirk);
// downstream SSE consumers backfill via /items-changes on a 0.
var deleteSeq int64
if d, derr := s.store.GetItemIncludeDeleted(item.ID); derr == nil && d != nil {
deleteSeq = d.Seq
}
actor, source := actorFromRequest(r)
s.logActivity(workspaceID, item.ID, "archived", r)
s.publishItemEventWithName(sseItemArchived, workspaceID, item.ID, item.Title, item.CollectionSlug, actor, actorNameFromRequest(r), source, deleteSeq)
w.WriteHeader(http.StatusNoContent)
}
// handleRestoreItem restores an archived item.
func (s *Server) handleRestoreItem(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
// We need to find the item even if deleted (for restore).
item, err := s.store.ResolveItemIncludeDeleted(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found or not archived")
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
// Check edit permission (grant-aware for guests)
if !s.requireEditPermission(w, r, workspaceID, item.ID, item.CollectionID) {
return
}
// BUG-3101: a restore raises items_per_workspace's live count. The
// advisory pre-check runs only for an archived row, so a live item still
// answers not-found below; RestoreItem decides authoritatively.
if item.DeletedAt != nil && !s.enforcePlanLimit(w, r, workspaceID, "items_per_workspace") {
return
}
// Resolved before the write, as in handleUpdateItem (BUG-3334).
restoreVisIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
restored, err := s.store.RestoreItem(item.ID, s.restoreLimitOpts()...)
if err != nil {
if writeStorePlanLimitError(w, r, err, "") {
return
}
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "not_found", "Item not found or not archived")
return
}
// If restore flips the partial unique index back into play and a
// replacement playbook has already claimed the archived item's
// invocation_slug, RestoreItem returns a UNIQUE constraint
// violation. Map it to 409, matching the create/update paths.
if strings.Contains(err.Error(), "UNIQUE constraint") || strings.Contains(err.Error(), "duplicate key") {
writeError(w, http.StatusConflict, "conflict", "Cannot restore: another item has claimed this slug or invocation slug")
return
}
writeInternalError(w, err)
return
}
// TASK-3365: a restored item is reachable again through grants it kept,
// so connections on this workspace re-check now (codex r2 on PR2).
s.invalidateWorkspaceAccess(item.WorkspaceID)
actor, source := actorFromRequest(r)
s.logActivity(workspaceID, restored.ID, "restored", r)
s.publishItemEventWithName(sseItemRestored, workspaceID, restored.ID, restored.Title, restored.CollectionSlug, actor, actorNameFromRequest(r), source, restored.Seq)
if err := s.enrichItemForResponse(r, restored, restoreVisIDs); err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, restored)
}
// handleMoveItem moves an item to a different collection with field migration.
// itemMoveRequest is the move endpoint's wire shape.
type itemMoveRequest struct {
TargetCollection string `json:"target_collection"`
FieldOverrides models.FieldValues `json:"field_overrides"`
}
// UnmarshalJSON refuses a repeated field_overrides (BUG-3219): FieldValues
// keeps only the last occurrence, where the plain map before BUG-3202 merged.
func (m *itemMoveRequest) UnmarshalJSON(data []byte) error {
type alias itemMoveRequest
return refuseRepeatedFieldValues(json.Unmarshal(data, (*alias)(m)), data, "field_overrides")
}
func (s *Server) handleMoveItem(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
s.writeItemResolveError(w, r, workspaceID, itemSlug)
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
// Check edit permission (grant-aware for guests)
if !s.requireEditPermission(w, r, workspaceID, item.ID, item.CollectionID) {
return
}
var input itemMoveRequest
if err := decodeJSON(r, &input); err != nil {
if rep := (*models.RepeatedMemberError)(nil); errors.As(err, &rep) {
writeError(w, http.StatusBadRequest, "invalid_body", rep.Error())
return
}
writeError(w, http.StatusBadRequest, "invalid_body", err.Error())
return
}
if input.TargetCollection == "" {
writeError(w, http.StatusBadRequest, "missing_field", "target_collection is required")
return
}
// Get target collection, verify it's visible, and check edit permission
targetColl, err := s.resolveItemCollectionSlug(workspaceID, input.TargetCollection)
if err != nil || targetColl == nil {
writeError(w, http.StatusBadRequest, "invalid_collection", "Target collection not found")
return
}
targetVisibleIDs, visErr := s.visibleCollectionIDs(r, workspaceID)
if visErr != nil {
writeInternalError(w, visErr)
return
}
if !isCollectionVisible(targetColl.ID, targetVisibleIDs) {
writeError(w, http.StatusBadRequest, "invalid_collection", "Target collection not found")
return
}
// Require edit permission on the target collection (not just visibility)
if !s.requireEditPermission(w, r, workspaceID, "", targetColl.ID) {
return
}
// Don't move to the same collection
if targetColl.ID == item.CollectionID {
writeError(w, http.StatusBadRequest, "same_collection", "Item is already in this collection")
return
}
// Get source collection for schema
sourceColl, err := s.store.GetCollection(item.CollectionID)
if err != nil || sourceColl == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get source collection")
return
}
// Parse schemas
var sourceSchema, targetSchema models.CollectionSchema
if err := json.Unmarshal([]byte(sourceColl.Schema), &sourceSchema); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to parse source schema")
return
}
if err := json.Unmarshal([]byte(targetColl.Schema), &targetSchema); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to parse target schema")
return
}
// Parse current fields
var currentFields map[string]any
if err := models.DecodeJSONKeepingNumbers([]byte(item.Fields), &currentFields); err != nil {
currentFields = make(map[string]any)
}
// Migrate fields
// SameWorkspace is a property of the endpoint, not a guess: a move
// changes the item's COLLECTION and cannot change its workspace — the
// cross-workspace path is the copy endpoint. So the repo/member
// context around the item is unchanged and referential system
// metadata still describes something true (BUG-2674).
result := items.MigrateFields(currentFields, sourceSchema.Fields, targetSchema.Fields, items.SameWorkspace)
// Apply overrides.
//
// Reserved keys are REFUSED rather than merged (Codex round 4). They are
// checked FIRST, and separately from the declared-key gate below, so a
// reserved key gets the message that names why — without this an override could write
// arbitrary junk straight into implementation_notes or decision_log,
// bypassing both the schema (which does not declare them) and the
// migrated-output validation (which strips them). On the copy the same
// hole additionally defeats the scope rule: an override could reintroduce
// a github_pr that MigrateFields had just dropped for leaving its
// workspace.
//
// Refusing is the honest answer, not silently dropping the key: a caller
// that asked for a value and got an item without it has no way to tell.
// System metadata is written through its own endpoints — `pad item note`,
// `pad item decide`, `pad github link` — which is where the append guard
// from BUG-2627 lives.
if bad := items.ReservedFieldKeysIn(input.FieldOverrides); len(bad) > 0 {
writeError(w, http.StatusBadRequest, "malformed_override",
fmt.Sprintf("Field(s) reserved for system metadata and not settable here: %s", strings.Join(bad, ", ")))
return
}
// An override must name a field the DESTINATION declares (BUG-2379). The
// same check, against the same stripped schema, with the same code and
// message, as the cross-workspace copy (structuralOverrideError): the two
// sibling endpoints used to disagree about one request field, and this
// one merged an undeclared key into the item as an invisible orphan.
//
// Refused here although item create and update ACCEPT undeclared keys with
// a warning (BUG-2850). That acceptance exists because clients round-trip
// the whole fields blob, so a stored orphan re-arrives on every ordinary
// update. An override is never round-tripped state; it is a fresh
// assertion about the destination schema. Orphans already on the item are
// carried by MigrateFields above, not by this map, so they are untouched.
if bad := items.UndeclaredOverrideKeys(input.FieldOverrides, items.SchemaForMigratedFields(targetSchema).Fields); len(bad) > 0 {
writeError(w, http.StatusBadRequest, "malformed_override",
"Destination collection has no field(s): "+summarizeKeys(bad))
return
}
for k, v := range input.FieldOverrides {
if v == nil {
// An explicit null means "leave this unset": DELETE rather than
// assign nil, exactly as the copy does (items_cross_workspace_copy.go,
// where the reason is recorded). Assigning persisted a literal
// `"key": null` and suppressed the schema default; codex round 1 on
// BUG-2379 found the two siblings disagreeing here too.
delete(result.Fields, k)
continue
}
result.Fields[k] = v
}
// Validate AFTER the overrides are applied — PLAN-2357 DR-12.
//
// The comment above the old check claimed "after overrides" but the
// value it tested, result.Errors, is computed by MigrateFields BEFORE
// any override exists (migrate.go:62). So an override that SATISFIED a
// required destination field still 400'd, and an override with an
// invalid value was never type-checked at all — it went straight into
// the item. Both halves are fixed by running the validator over the
// MERGED map instead; ValidateFieldsDetailed also injects destination
// defaults, which is why result.Fields is what gets serialized below.
//
// Dormant until now — handleMove passes field_overrides: undefined
// (ItemDetail.svelte), so no override ever reached the stale check.
// PLAN-2357's copy dialog is the first caller to send them, which is
// what makes this the PR that has to fix it rather than file it.
//
// The required-field branch keeps the historical
// `missing_required_fields` code and message shape, because CLI and
// web callers key off it; genuinely invalid VALUES get their own
// `invalid_fields` code rather than being mislabelled as missing.
// Coerce strings to their declared types before validating (BUG-2850).
result.Fields = items.CoerceFields(result.Fields, items.SchemaForMigratedFields(targetSchema))
// BUG-3028: an override is a value this move SETS; a blank one is removed
// before validation so a required target field is refused. Blanks carried
// from the source are removed after validation.
items.DropBlankRelations(result.Fields, items.SchemaForMigratedFields(targetSchema), func(k string) bool {
_, set := input.FieldOverrides[k]
return !set
})
// Relation referents on a SAME-WORKSPACE move (TASK-2878). A migrate door,
// so provenance decides: an explicit override refuses, a carried value
// resolves and survives if it can. The targets are still in this
// workspace, so a correctly-related item keeps its relation across the
// move; only an unresolvable value is dropped, and it joins the same
// `dropped_fields` report BUG-2674 established rather than failing the
// move. Refusing carried values here would make every legacy item — and
// `internal/items` has accepted any string for a relation all along —
// permanently unmovable.
// The supplied half is a write, so it owes the same visibility check the
// four write doors run. The store resolver cannot do it — see
// refuseInvisibleRelationOverrides. Same workspace here, so the role
// stashed for this request is the right one.
if invisible, err := s.refuseInvisibleRelationOverrides(
r, workspaceID, workspaceRole(r), items.SchemaForMigratedFields(targetSchema),
input.FieldOverrides); err != nil {
writeInternalError(w, err)
return
} else if refuseRelationIssues(w, invisible) {
return
}
relRefusals, relDropped, relErr := s.store.MigrateRelationReferents(
s.relationVisibility(r, workspaceRole(r)),
workspaceID, items.SchemaForMigratedFields(targetSchema), result.Fields,
input.FieldOverrides, store.CarriedSourceValues(currentFields, result.Dropped),
store.RelationCarryWithinWorkspace)
if relErr != nil {
writeInternalError(w, relErr)
return
}
if refuseRelationIssues(w, relRefusals) {
return
}
for _, ri := range relDropped {
result.Dropped = append(result.Dropped, ri.Key)
}
// A CARRIED value colliding on a destination unique field is dropped and
// reported, before validation so a required one is refused as required;
// an override that collides is left for the final check (BUG-2367).
notUnique, nuErr := s.store.DropCarriedUniqueCollisionsQ(s.store.Q(),
s.relationVisibility(r, workspaceRole(r)), workspaceID, targetColl,
targetSchema.Fields, result.Fields, func(k string) bool {
_, set := input.FieldOverrides[k]
return set
})
if nuErr != nil {
writeInternalError(w, nuErr)
return
}
for _, d := range notUnique {
result.Dropped = append(result.Dropped, d.Key)
}
// Snapshot AFTER the pass above and BEFORE validation: what this needs to
// identify is exactly what VALIDATION adds — which includes a default the
// pass just deleted as unresolvable and validation puts straight back.
// Snapshotting before the pass would treat that key as already examined
// and skip it, which is the arrangement that hid it.
relBefore := store.RelationKeysPresent(items.SchemaForMigratedFields(targetSchema), result.Fields)
if issues := items.ValidateFieldsDetailed(result.Fields, items.SchemaForMigratedFields(targetSchema)); len(issues) > 0 {
var missing, invalid []string
for _, iss := range issues {
if iss.Kind == items.IssueRequired {
missing = append(missing, fmt.Sprintf("required field %q has no value", iss.Key))
} else {
invalid = append(invalid, iss.Message)
}
}
if len(missing) > 0 {
writeError(w, http.StatusBadRequest, "missing_required_fields",
fmt.Sprintf("Required fields missing: %s", strings.Join(missing, ", ")))
return
}
writeError(w, http.StatusBadRequest, "invalid_fields",
fmt.Sprintf("Invalid field value(s): %s", strings.Join(invalid, "; ")))
return
}
items.DropBlankRelations(result.Fields, items.SchemaForMigratedFields(targetSchema), nil)
// Relation defaults ValidateFieldsDetailed just injected, which the pass
// above could not have seen (codex round 2). AFTER validation, not before:
// the required-field check has to see a value referent resolution dropped,
// which is why the main pass runs first — see ResolveLateRelationDefaults.
lateDropped, lateErr := s.store.ResolveLateRelationDefaults(
s.relationVisibility(r, workspaceRole(r)),
workspaceID, items.SchemaForMigratedFields(targetSchema), result.Fields, relBefore)
if lateErr != nil {
writeInternalError(w, lateErr)
return
}
if cerr := s.collapseInvisibleRelationIssues(r, workspaceID, workspaceRole(r), lateDropped); cerr != nil {
writeInternalError(w, cerr)
return
}
// A REQUIRED relation whose default did not resolve cannot be left as a
// drop: the key is deleted AFTER validation passed, so nothing re-checks
// it and the item would land with a required field absent, reported valid
// (codex round 3). Re-running validation is not the answer — it would
// re-inject the same broken default. There is no valid value, so this
// refuses.
if req := store.RequiredRelationIssues(items.SchemaForMigratedFields(targetSchema), lateDropped); len(req) > 0 {
writeError(w, http.StatusBadRequest, "missing_required_fields",
"Required fields missing: "+relationIssuesMessage(req))
return
}
invisibleDefaults, invErr := s.dropInvisibleRelationDefaults(r, workspaceID, workspaceRole(r),
items.SchemaForMigratedFields(targetSchema), result.Fields,
notDefaultKeys(input.FieldOverrides, store.CarriedSourceValues(currentFields, result.Dropped)))
if invErr != nil {
writeInternalError(w, invErr)
return
}
if req := store.RequiredRelationIssues(items.SchemaForMigratedFields(targetSchema), invisibleDefaults); len(req) > 0 {
writeError(w, http.StatusBadRequest, "missing_required_fields",
"Required fields missing: "+relationIssuesMessage(req))
return
}
for _, ri := range append(lateDropped, invisibleDefaults...) {
result.Dropped = append(result.Dropped, ri.Key)
}
// A carried value on a unique_scope field must be unique in the collection
// it is moving INTO (BUG-2367). Checked on the final map, so an override
// that replaces the colliding value passes.
// A move that would change the item's open/done/abandoned state must be
// asked for, not inferred from whichever value survived migration or
// whichever default the destination injected (BUG-2367 item 4).
if ch := models.MigrateCloseStateChange(
currentFields, sourceSchema, collectionSettingsOf(sourceColl),
result.Fields, targetSchema, collectionSettingsOf(targetColl),
func(k string) bool { _, set := input.FieldOverrides[k]; return set },
); ch != nil {
writeError2(w, http.StatusBadRequest, closeStateChangeCode, ch.Message(), ch.Details())
return
}
if conflicts, uerr := s.store.UniqueFieldConflictsQ(s.store.Q(), targetColl.ID, item.ID, targetSchema.Fields, result.Fields); uerr != nil {
writeInternalError(w, uerr)
return
} else if len(conflicts) > 0 {
writeError(w, http.StatusConflict, "conflict", store.UniqueFieldConflictsMessage(conflicts))
return
}
// Serialize migrated fields
fieldsJSON, err := json.Marshal(result.Fields)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to serialize fields")
return
}
// IDEA-1494 R3 P1: same open-children guard as the regular update
// path. `pad item move ... --field status=completed` would
// otherwise bypass it. We classify the proposed transition
// against the DESTINATION collection's schema (conservative —
// honors the schema the item is moving INTO, so a target-schema
// terminal value can't be smuggled in via a source whose schema
// doesn't recognize the value as terminal). Visibility filter is
// pre-computed and fails closed on lookup error, mirroring the
// update path.
//
// Force override on the move path lives on the URL query
// (?force=true). Move's request body shape is already fixed
// ({target_collection, field_overrides}) and adding a body
// field would require coordinating with the CLI/MCP move
// callers; the query param sidesteps that without changing the
// JSON contract. Same semantics as `pad item update --force`.
moveForce := r.URL.Query().Get("force") == "true"
var guardPrecheck func(tx *sql.Tx, existing *models.Item) error
if !moveForce {
var destSettings models.CollectionSettings
if targetColl.Settings != "" {
_ = json.Unmarshal([]byte(targetColl.Settings), &destSettings)
}
visIDs, visErr := s.visibleCollectionIDs(r, workspaceID)
if visErr != nil {
writeInternalError(w, visErr)
return
}
guestFull, guestGranted, gerr := s.guestResourceFilter(r, workspaceID)
if gerr != nil {
writeInternalError(w, gerr)
return
}
mgctx := openChildrenGuardContext{
r: r,
workspaceID: workspaceID,
itemID: item.ID,
parentSchema: targetSchema,
parentSettings: destSettings,
newFieldMap: result.Fields,
visibleCollectionIDs: visIDs,
guestFullCollIDs: guestFull,
guestGrantedItemIDs: guestGranted,
}
guardPrecheck = func(tx *sql.Tx, existing *models.Item) error {
txCtx := mgctx
txCtx.currentFieldsJS = existing.Fields
details, derr := s.runOpenChildrenGuard(tx, txCtx)
if derr != nil {
return derr
}
if details != nil {
return &openChildrenGuardError{details: details}
}
return nil
}
}
// The payload above was built from reads taken before the move takes its
// lock (BUG-3665); moveInputsUnchanged re-checks them, forced or not.
movePrecheck := s.moveInputsUnchanged(item, sourceColl, targetColl, targetSchema.Fields, result.Fields, guardPrecheck)
// Move the item
// BUG-3407: the target schema the moved fields were validated against.
moved, err := s.store.MoveItemWithPreCheck(item.ID, targetColl.ID, string(fieldsJSON), movePrecheck, store.WithValidatedSchema(targetColl.Schema))
if err != nil {
if errors.Is(err, errItemChangedDuringMove) {
writeError(w, http.StatusConflict, "conflict", "The item or its collections changed while it was being moved; reload it and move it again")
return
}
var uc *moveUniqueConflictError
if errors.As(err, &uc) {
writeError(w, http.StatusConflict, "conflict", uc.msg)
return
}
if details, ok := asOpenChildrenGuardError(err); ok {
writeOpenChildrenError(w, itemRefOrSlug(*item), details)
return
}
// BUG-3407: the target schema stopped allowing the moved fields.
if v, ok := store.AsValidationError(err); ok {
writeError(w, http.StatusBadRequest, "validation_error", v.Reason)
return
}
writeInternalError(w, err)
return
}
// TASK-3365: a move can take an item out of a collection a guest or a
// restricted member may see; connections on this workspace re-check now.
s.invalidateWorkspaceAccess(item.WorkspaceID)
// Log activity with metadata about the move (audit trail). The
// moved-out tombstone signal (BUG-1675) is recorded durably in
// item_collection_moves inside the move tx, not derived from this
// best-effort row.
actor, source := actorFromRequest(r)
moveAudit := map[string]string{"from_collection": sourceColl.Slug, "to_collection": targetColl.Slug}
// A move that discards field values says so (BUG-2674). MigrateFields
// has always reported them in result.Dropped and this handler has
// always thrown that away, so the only record of a field disappearing
// was the field being gone. Reserved keys no longer appear here at all
// (they carry), which leaves this list meaning what it says: values the
// TARGET SCHEMA has no home for.
//
// It rides the audit metadata rather than the response body because
// the response is the bare item — a wrapper would break every existing
// consumer — and because the activity timeline is where someone asking
// "what happened to my item" actually looks. Joined into one string:
// this map is map[string]string, and a raw array here renders as a Go
// map literal in the timeline (BUG-2628).
//
// result.Dropped is computed by MigrateFields BEFORE overrides are merged
// and before validation injects destination defaults, so a key it lists
// may have been supplied moments later. Reporting those would be a
// confident falsehood — "we discarded your due_date" about an item that
// HAS a due_date — which is worse than the silence this replaced, because
// silence at least does not send someone looking for lost data that is
// sitting on the item. Filtering against the FINAL map is what makes the
// report true at the moment it is written (Codex round 2 P2-2).
if dropped := items.StillDropped(result.Dropped, result.Fields); len(dropped) > 0 {
moveAudit["dropped_fields"] = strings.Join(dropped, ", ")
}
if len(notUnique) > 0 {
moveAudit["not_unique"] = notUniqueSummary(notUnique, targetColl.Name)
}
s.logActivityWithMeta(workspaceID, moved.ID, "moved", r, auditMeta(moveAudit))
// Publish events for both old and new collections
actorNameForMove := actorNameFromRequest(r)
s.publishItemEventWithName(sseItemMoved, workspaceID, moved.ID, moved.Title, targetColl.Slug, actor, actorNameForMove, source, moved.Seq)
s.publishWatchNotifications(workspaceID, moved, actor, actorNameForMove)
// The visibility checked before the write (BUG-3334).
if err := s.enrichItemForResponse(r, moved, targetVisibleIDs); err != nil {
writeInternalError(w, err)
return
}
if len(notUnique) > 0 {
// The move's only drop the response names: for invocation_slug the
// item has stopped answering to that slug, and the caller has to
// learn that from the answer to its own request (BUG-2367).
if moved.Warnings == nil {
moved.Warnings = &models.ItemWriteWarnings{}
}
moved.Warnings.NotUnique = notUnique
}
writeJSON(w, http.StatusOK, moved)
}
// notUniqueSummary joins not_unique drops into the one line an activity row
// carries (BUG-2367). Always the holder-free sentence: the row is read by
// everyone who can see the moved item, and the holder was named only because
// the ACTOR may see it.
func notUniqueSummary(drops []models.NotUniqueDrop, collectionName string) string {
msgs := make([]string, 0, len(drops))
for _, d := range drops {
msgs = append(msgs, store.NotUniqueAnonymousMessage(d.Key, d.Value, collectionName))
}
return strings.Join(msgs, "; ")
}
// publishItemEventWithName publishes a real-time event for item changes with actor name.
// `seq` is the item's workspace-scoped monotonic mutation cursor at the
// time of the event (PLAN-1343 / TASK-1358), so SSE consumers can
// reason about ordering and contiguity. Callers that don't have the
// item's current seq handy may pass 0; downstream consumers fall back
// to a /items-changes backfill in that case.
func (s *Server) publishItemEventWithName(eventType, workspaceID, itemID, title, collection, actor, actorName, source string, seq int64) {
if s.events == nil {
return
}
s.publishActivityEvent(events.Event{
Type: eventType,
WorkspaceID: workspaceID,
ItemID: itemID,
Collection: collection,
Title: title,
Actor: actor,
ActorName: actorName,
Source: source,
Seq: seq,
})
}
// handleCollectionCheckboxProgress returns per-item markdown-checkbox
// progress for items in a single collection — the bookkeeping that
// powers the list/board/table progress badges for non-plans
// collections. Pairs with /items-index (TASK-1349 / PLAN-1343 Phase 1):
// the index endpoint omits content to keep the wire payload small,
// and this endpoint computes the checkbox counts server-side via
// LENGTH/REPLACE arithmetic so the client doesn't need content at
// all to render the progress UI.
//
// Visibility: enforces the same collection-visibility + item-grant
// rules as handleListItems / handleListItemsIndex. Items the caller
// can't see contribute zero rows to the response.
func (s *Server) handleCollectionCheckboxProgress(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
collSlug := chi.URLParam(r, "collSlug")
coll, err := s.store.GetCollectionBySlug(workspaceID, collSlug)
if err != nil {
writeInternalError(w, err)
return
}
if coll == nil {
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
// Collection-visibility gate.
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
if !isCollectionVisible(coll.ID, visibleIDs) {
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
includeArchived := r.URL.Query().Get("include_archived") == "true"
progress, err := s.store.CollectionCheckboxProgress(workspaceID, coll.ID, includeArchived)
if err != nil {
writeInternalError(w, err)
return
}
// Item-grant filtering: if the caller has item-level grants (guest
// / restricted member) and the collection isn't fully granted, drop
// rows for items outside their grant set. This mirrors the same
// filter handleListItems / handleListItemsIndex apply via
// guestResourceFilter — without it, a guest could enumerate the
// existence of items they can't otherwise see by reading their
// checkbox progress.
fullCollIDs, grantedItemIDs, grantErr := s.guestResourceFilter(r, workspaceID)
if grantErr != nil {
writeInternalError(w, grantErr)
return
}
if len(grantedItemIDs) > 0 {
hasFullCollectionGrant := false
for _, id := range fullCollIDs {
if id == coll.ID {
hasFullCollectionGrant = true
break
}
}
if !hasFullCollectionGrant && workspaceRole(r) != "guest" {
memberColls, _ := s.store.GetMemberCollectionAccess(workspaceID, currentUserID(r))
for _, id := range memberColls {
if id == coll.ID {
hasFullCollectionGrant = true
break
}
}
}
if !hasFullCollectionGrant {
granted := make(map[string]struct{}, len(grantedItemIDs))
for _, id := range grantedItemIDs {
granted[id] = struct{}{}
}
filtered := progress[:0]
for _, p := range progress {
if _, ok := granted[p.ItemID]; ok {
filtered = append(filtered, p)
}
}
progress = filtered
}
}
if progress == nil {
progress = []store.ItemCheckboxProgress{}
}
writeJSON(w, http.StatusOK, progress)
}
// collectionChildrenProgress is the shared implementation for child-item
// completion progress across any collection. It enforces the same
// visibility/guest-grant filtering that the plans-specific path did,
// generalised to an arbitrary collectionSlug.
//
// The caller is responsible for verifying that the collection exists and is
// visible before calling this helper — the helper does NOT write HTTP
// responses.
//
// N+1 note: the restricted-visibility recompute path issues one GetChildItems
// query per parent item. For large collections (e.g. tasks with hundreds of
// items and a guest caller) this can be expensive. The plans endpoint shared
// the same trade-off; it was tolerable for small plan sets. Fixing this
// requires a single-query recompute (e.g. a bulk join limited to visible
// child collections) — tracked as a known limitation, deferred.
func (s *Server) collectionChildrenProgress(
r *http.Request,
workspaceID string,
collectionSlug string,
collectionID string,
visibleIDs []string,
fullCollIDs []string,
grantedItemIDs []string,
includeArchived bool,
) ([]store.ItemProgress, error) {
allProgress, err := s.store.GetAllItemProgress(workspaceID, collectionSlug, includeArchived)
if err != nil {
return nil, err
}
// For guests with item-level grants, filter the parent items themselves
// so callers without a grant to a specific parent don't see its row.
if len(grantedItemIDs) > 0 {
grantedSet := make(map[string]bool, len(grantedItemIDs))
for _, id := range grantedItemIDs {
grantedSet[id] = true
}
fullSet := make(map[string]bool, len(fullCollIDs))
for _, id := range fullCollIDs {
fullSet[id] = true
}
// Check if this collection has a full grant (bypass item-level filter).
if !fullSet[collectionID] {
filtered := allProgress[:0]
for _, p := range allProgress {
if grantedSet[p.ItemID] {
filtered = append(filtered, p)
}
}
allProgress = filtered
}
}
// Build a done-context map once so each child is evaluated against its
// own collection's configured done field, not a global default.
// ListCollectionsMinimal avoids the per-collection COUNT queries that
// ListCollections would run — we only need schema + settings.
wsCollections, _ := s.store.ListCollectionsMinimal(workspaceID)
ctxMap := buildDoneContextMap(wsCollections)
// Recompute each parent's progress using only visible children so hidden
// child counts don't leak through the aggregate totals.
for i, p := range allProgress {
children, cerr := s.store.GetChildItems(p.ItemID)
if cerr != nil {
continue
}
total, done := 0, 0
for _, child := range children {
if !isCollectionVisible(child.CollectionID, visibleIDs) {
continue
}
if !s.isItemVisibleToGuest(r, workspaceID, &child, fullCollIDs, grantedItemIDs) {
continue
}
counted, isDone := childProgressState(child.Fields, child.CollectionID, ctxMap)
if !counted {
continue
}
total++
if isDone {
done++
}
}
allProgress[i].Total = total
allProgress[i].Done = done
}
return allProgress, nil
}
// handlePlansProgress returns child item completion progress for all
// non-deleted plans. Kept as a backward-compat endpoint at /plans-progress;
// delegates to collectionChildrenProgress for the shared visibility logic.
func (s *Server) handlePlansProgress(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
fullCollIDs, grantedItemIDs, grantErr := s.guestResourceFilter(r, workspaceID)
if grantErr != nil {
writeInternalError(w, grantErr)
return
}
// Check that the plans collection is visible to this user.
plansColl, _ := s.store.GetCollectionBySlug(workspaceID, "plans")
if visibleIDs != nil {
if plansColl == nil || !isCollectionVisible(plansColl.ID, visibleIDs) {
writeJSON(w, http.StatusOK, []interface{}{})
return
}
}
if visibleIDs != nil {
collID := ""
if plansColl != nil {
collID = plansColl.ID
}
// /plans-progress does not support include_archived — plans are
// always queried without archived parents (false).
progress, err := s.collectionChildrenProgress(r, workspaceID, "plans", collID, visibleIDs, fullCollIDs, grantedItemIDs, false)
if err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, progress)
return
}
progress, err := s.store.GetAllItemProgress(workspaceID, "plans", false)
if err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, progress)
}
// handleCollectionChildrenProgress returns child-item completion progress for
// all items in an arbitrary collection. It preserves the same
// visibility/guest-grant filtering semantics as handlePlansProgress so
// restricted callers can't enumerate hidden child-item counts.
// Supports ?include_archived=true to include soft-deleted parent rows,
// matching the collection page's archived-items toggle.
//
// Route: GET /workspaces/{ws}/collections/{collSlug}/child-progress
func (s *Server) handleCollectionChildrenProgress(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
collSlug := chi.URLParam(r, "collSlug")
coll, err := s.store.GetCollectionBySlug(workspaceID, collSlug)
if err != nil {
writeInternalError(w, err)
return
}
if coll == nil {
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
// Collection-visibility gate: a restricted user without visibility into
// this collection must receive the same empty/denied shape they'd get
// from any other guarded endpoint — not child counts for a hidden
// collection.
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
if visibleIDs != nil && !isCollectionVisible(coll.ID, visibleIDs) {
writeJSON(w, http.StatusOK, []interface{}{})
return
}
includeArchived := r.URL.Query().Get("include_archived") == "true"
fullCollIDs, grantedItemIDs, grantErr := s.guestResourceFilter(r, workspaceID)
if grantErr != nil {
writeInternalError(w, grantErr)
return
}
if visibleIDs != nil {
progress, err := s.collectionChildrenProgress(r, workspaceID, collSlug, coll.ID, visibleIDs, fullCollIDs, grantedItemIDs, includeArchived)
if err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, progress)
return
}
progress, err := s.store.GetAllItemProgress(workspaceID, collSlug, includeArchived)
if err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, progress)
}
// handleGetItemChildren returns all child items linked to a parent item.
// This is the generalized version — children can come from any collection.
func (s *Server) handleGetItemChildren(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItemIncludeDeleted(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
// Visibility is applied in the query and the list is windowed (BUG-3789),
// so the has-more probe counts only children the caller may see.
visibleIDs, visErr := s.visibleCollectionIDs(r, workspaceID)
if visErr != nil {
writeInternalError(w, visErr)
return
}
fullCollIDs, grantedItemIDs, grantErr := s.guestResourceFilter(r, workspaceID)
if grantErr != nil {
writeInternalError(w, grantErr)
return
}
limit, offset := parseListWindow(r)
params := store.ChildListParams{Limit: limit + 1, Offset: offset}
if visibleIDs != nil {
params.CollectionIDs = visibleIDs
params.FullCollectionIDs, params.GrantedItemIDs = fullCollIDs, grantedItemIDs
}
children, err := s.store.ListChildItems(item.ID, params)
if err != nil {
writeInternalError(w, err)
return
}
children = setListWindowHeaders(w, children, limit, offset)
if children == nil {
children = []models.Item{}
}
s.enrichItemsWithParent(r, workspaceID, children, visibleIDs)
if visibleIDs != nil {
// Visibility-aware has_children: only count visible grandchildren
// One visible grandchild decides it; the probe reads one row
// (BUG-3789: it read every grandchild, with bodies, per child).
probe := params
probe.Limit, probe.Offset = 1, 0
for i := range children {
grandchildren, err := s.store.ListChildItems(children[i].ID, probe)
if err != nil {
writeInternalError(w, err)
return
}
children[i].HasChildren = len(grandchildren) > 0
}
} else {
s.store.PopulateHasChildren(children)
}
writeJSON(w, http.StatusOK, children)
}
// handleGetItemProgress returns completion progress for an item's children.
// Response: {"total": N, "done": N, "percentage": N}
func (s *Server) handleGetItemProgress(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItemIncludeDeleted(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
// Get visibility filter; when restricted, compute progress from
// visible children only so hidden child counts don't leak.
progVisIDs, progVisErr := s.visibleCollectionIDs(r, workspaceID)
if progVisErr != nil {
writeInternalError(w, progVisErr)
return
}
progFullCollIDs, progGrantedItemIDs, progGrantErr := s.guestResourceFilter(r, workspaceID)
if progGrantErr != nil {
writeInternalError(w, progGrantErr)
return
}
if progVisIDs != nil {
// Restricted: compute from visible children in Go using per-collection
// schemas to determine terminal statuses correctly.
children, cerr := s.store.GetChildItems(item.ID)
if cerr != nil {
writeInternalError(w, cerr)
return
}
// Cache a schema+settings done-context per child collection so
// each child is evaluated against its own configured done field.
ctxCache := make(map[string]doneContext)
total, done := 0, 0
for _, child := range children {
if !isCollectionVisible(child.CollectionID, progVisIDs) {
continue
}
if !s.isItemVisibleToGuest(r, workspaceID, &child, progFullCollIDs, progGrantedItemIDs) {
continue
}
ctx, cached := ctxCache[child.CollectionID]
if !cached {
if coll, cerr := s.store.GetCollection(child.CollectionID); cerr == nil && coll != nil {
_ = models.UnmarshalItemFieldSchema([]byte(coll.Schema), &ctx.schema)
if coll.Settings != "" {
_ = json.Unmarshal([]byte(coll.Settings), &ctx.settings)
}
}
ctxCache[child.CollectionID] = ctx
}
// A one-entry ctx map so the typed-context branch is taken
// rather than the status-only fallback.
counted, isDone := childProgressState(child.Fields, child.CollectionID, map[string]doneContext{child.CollectionID: ctx})
if !counted {
continue
}
total++
if isDone {
done++
}
}
pct := 0
if total > 0 {
pct = (done * 100) / total
}
writeJSON(w, http.StatusOK, map[string]int{
"total": total,
"done": done,
"percentage": pct,
})
return
}
total, done, err := s.store.GetItemProgress(item.ID)
if err != nil {
writeInternalError(w, err)
return
}
pct := 0
if total > 0 {
pct = (done * 100) / total
}
writeJSON(w, http.StatusOK, map[string]int{
"total": total,
"done": done,
"percentage": pct,
})
}
// resolveParentFilter extracts a "parent" (or "plan") key from the field
// filters and converts it to a ParentLinkID filter (which uses item_links instead of json_extract).
// An optional schema can be passed; if the schema defines a field with the key,
// that key is left as a normal field filter instead of being treated as a parent link.
func (s *Server) resolveParentFilter(r *http.Request, workspaceID string, params *models.ItemListParams, schemas ...models.CollectionSchema) error {
if params.Fields == nil && params.FieldsAnyOf == nil {
return nil
}
// Accept both "parent" and "plan" as parent filter keys
// but skip if the schema defines a real field with that key
var schema *models.CollectionSchema
if len(schemas) > 0 {
schema = &schemas[0]
}
var val string
for _, key := range []string{"parent", "plan"} {
if schema != nil && schemaHasField(*schema, key) {
continue
}
if v, ok := params.Fields[key]; ok && v != "" {
val = v
delete(params.Fields, key)
break
}
// A comma-containing parent value is lowered to FieldsAnyOf by
// parseItemListParams (BUG-3167). A parent filter names ONE item, so
// rejoin it and let the lookup below refuse it, exactly as the whole
// string was refused before the lowering existed.
if pieces, ok := params.FieldsAnyOf[key]; ok && len(pieces) > 0 {
val = strings.Join(pieces, ",")
delete(params.FieldsAnyOf, key)
break
}
}
if val == "" {
return nil
}
// Resolve slug/ref to UUID
var resolved *models.Item
if !isUUID(val) {
var rerr error
resolved, rerr = s.store.ResolveItem(workspaceID, val)
if rerr != nil || resolved == nil {
return fmt.Errorf("parent %q not found", val)
}
params.ParentLinkID = resolved.ID
} else {
params.ParentLinkID = val
resolved, _ = s.store.GetItem(val)
}
// Ensure the parent is visible — return the same not-found error for
// hidden parents so restricted users can't probe hidden item existence.
if resolved != nil {
visIDs, verr := s.visibleCollectionIDs(r, workspaceID)
if verr != nil {
return verr
}
if !isCollectionVisible(resolved.CollectionID, visIDs) {
return fmt.Errorf("parent %q not found", val)
}
}
return nil
}
// schemaHasField returns true if the collection schema defines a field with the given key.
func schemaHasField(schema models.CollectionSchema, key string) bool {
for _, f := range schema.Fields {
if f.Key == key {
return true
}
}
return false
}
// checkUniqueFields enforces FieldDef.UniqueScope == "workspace_collection"
// for the given collection. For each schema field with that scope, any
// non-empty string value in fieldMap is checked against existing items in
// the same collection; a match returns a conflict error. excludeItemID
// allows update flows to ignore the item being updated.
//
// Only string-typed unique values are supported today (the only consumer is
// playbooks.invocation_slug). Non-string values are skipped without error.
// extractParentLink pulls a "parent"/"plan" pseudo-field out of fieldMap —
// parent relationships are stored as item_links, not in the fields JSON — and
// resolves it to a target item ID. It mutates fieldMap (deleting the key it
// consumes) and returns (parentValue, parentProvided, ok). ok=false means an
// error response was already written to w and the caller must return
// immediately. Shared by the full-`fields` and `fields_patch` update paths
// (TASK-2022) so both resolve/validate the parent identically.
func (s *Server) extractParentLink(
w http.ResponseWriter,
r *http.Request,
workspaceID string,
schema models.CollectionSchema,
fieldMap map[string]any,
) (parentValue string, parentProvided bool, ok bool) {
for _, key := range []string{"parent", "plan"} {
if schemaHasField(schema, key) {
continue
}
pv, exists := fieldMap[key]
if !exists {
continue
}
parentProvided = true
if pv != nil {
if pvStr, isStr := pv.(string); isStr && pvStr != "" {
var resolvedParent *models.Item
var err error
if !isUUID(pvStr) {
resolvedParent, err = s.store.ResolveItem(workspaceID, pvStr)
if err != nil || resolvedParent == nil {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("parent %q not found", pvStr))
return "", false, false
}
} else {
resolvedParent, _ = s.store.GetItem(pvStr)
if resolvedParent == nil {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("parent %q not found", pvStr))
return "", false, false
}
}
parentValue = resolvedParent.ID
// Ensure parent item belongs to this workspace and is visible.
if resolvedParent.WorkspaceID != workspaceID {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("parent %q not found", pvStr))
return "", false, false
}
if !s.requireItemVisible(w, r, workspaceID, resolvedParent) {
return "", false, false
}
}
}
delete(fieldMap, key)
}
return parentValue, parentProvided, true
}
// uniqueEnforced reports whether checkUniqueFields enforces def. The app
// installer's preview uses the same predicate (TASK-3397), so the two cannot
// disagree about which fields are unique.
func uniqueEnforced(def models.FieldDef) bool { return def.UniqueScope == "workspace_collection" }
func (s *Server) checkUniqueFields(workspaceID, collectionID, excludeItemID string, schema models.CollectionSchema, fieldMap map[string]any) error {
return s.checkUniqueFieldsQ(s.store.Q(), workspaceID, collectionID, excludeItemID, schema, fieldMap)
}
// checkUniqueFieldsQ is checkUniqueFields on q. The lookup is
// store.ItemsWithFieldValueQ, ListItems' own Fields-filter predicate, so the
// pool path and the installer's in-transaction path share one implementation
// (TASK-3397, U8b).
func (s *Server) checkUniqueFieldsQ(q store.Queryer, workspaceID, collectionID, excludeItemID string, schema models.CollectionSchema, fieldMap map[string]any) error {
for _, def := range schema.Fields {
if !uniqueEnforced(def) {
continue
}
raw, ok := fieldMap[def.Key]
if !ok || raw == nil {
continue
}
val, ok := raw.(string)
if !ok || val == "" {
continue
}
// IncludeArchived stays false so the application-layer check stays
// consistent with the partial unique index's `deleted_at IS NULL`
// predicate. A soft-deleted playbook releases its slug back to the
// pool; trying to reclaim it should succeed, not 409.
existing, err := s.store.ItemsWithFieldValueQ(q, workspaceID, collectionID, def.Key, val, 2)
if err != nil {
return err
}
for _, item := range existing {
if item.ID == excludeItemID {
continue
}
return fmt.Errorf("field %q value %q is already used by item %s", def.Key, val, item.Slug)
}
}
return nil
}
func isUUID(s string) bool {
if len(s) != 36 {
return false
}
for i, c := range s {
if i == 8 || i == 13 || i == 18 || i == 23 {
if c != '-' {
return false
}
} else if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return true
}
// autoPopulateDates auto-fills start_date/end_date when status changes to active/completed.
// Only sets dates if the schema defines those date fields and the field is currently empty.
func autoPopulateDates(newFields map[string]any, existingFieldsJSON string, schema models.CollectionSchema) {
// Check if schema has date fields named start_date and/or end_date
hasStartDate := false
hasEndDate := false
for _, f := range schema.Fields {
if f.Key == "start_date" && f.Type == "date" {
hasStartDate = true
}
if f.Key == "end_date" && f.Type == "date" {
hasEndDate = true
}
}
if !hasStartDate && !hasEndDate {
return
}
// Get the new status value
newStatus, ok := newFields["status"].(string)
if !ok || newStatus == "" {
return
}
// Check if status actually changed
var oldFields map[string]any
if existingFieldsJSON != "" {
json.Unmarshal([]byte(existingFieldsJSON), &oldFields)
}
oldStatus, _ := oldFields["status"].(string)
if newStatus == oldStatus {
return
}
today := time.Now().Format("2006-01-02")
// Auto-set start_date when moving to active
if hasStartDate && newStatus == "active" {
existing, _ := newFields["start_date"].(string)
if existing == "" {
newFields["start_date"] = today
}
}
// Auto-set end_date when moving to completed
if hasEndDate && (newStatus == "completed" || newStatus == "done") {
existing, _ := newFields["end_date"].(string)
if existing == "" {
newFields["end_date"] = today
}
}
}
// maxItemListQueryLimit is the hard-max clamp applied to a caller-supplied
// `?limit=` on the item-list endpoints (TASK-2000). It matches the CLI's
// maxItemListLimit so both layers agree on the ceiling. A zero/absent limit is
// left unbounded — this only clamps an explicit oversized request.
const maxItemListQueryLimit = 1000
// parseItemListParams extracts item list parameters from the request query string.
func parseItemListParams(r *http.Request) models.ItemListParams {
params := models.ItemListParams{
Sort: r.URL.Query().Get("sort"),
GroupBy: r.URL.Query().Get("group_by"),
Search: r.URL.Query().Get("search"),
ParentID: r.URL.Query().Get("parent_id"),
Unparented: r.URL.Query().Get("unparented") == "true",
Tag: r.URL.Query().Get("tag"),
AssignedUserID: r.URL.Query().Get("assigned_user_id"),
AgentRoleID: r.URL.Query().Get("agent_role_id"),
}
if r.URL.Query().Get("include_archived") == "true" {
params.IncludeArchived = true
}
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil {
params.Limit = l
}
}
// Every HTTP item list is bounded (BUG-3704). A missing, zero, negative or
// invalid limit takes the MAXIMUM, not a smaller default: a client that
// does not read the has-more headers then loses rows only on lists past
// it, never on an ordinary one (lead ruling). An explicit limit above it
// is clamped (TASK-2000). This parser is the HTTP boundary only; internal
// callers of store.ListItems (dashboard, bootstrap, playbooks, ...) do not
// come through it and stay unbounded.
if params.Limit <= 0 || params.Limit > maxItemListQueryLimit {
params.Limit = maxItemListQueryLimit
}
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o > 0 {
params.Offset = o
}
}
// non_terminal=true restricts results to items that are NOT in their
// collection's terminal state, resolved per-collection from each
// schema's terminal_options (BUG-2001). This is the CLI/MCP default
// list behavior; an explicit status/field filter is applied on top.
if r.URL.Query().Get("non_terminal") == "true" {
params.NonTerminal = true
}
// Extract field filters: any query param that isn't a known param is a field
// filter. The handlers then refuse one that names no field (BUG-3480,
// checkListFilterKeys).
knownParams := map[string]bool{
"sort": true, "group_by": true, "search": true, "parent_id": true,
"tag": true, "include_archived": true, "limit": true, "offset": true,
"assigned_user_id": true, "agent_role_id": true, "non_terminal": true,
}
// A field filter value containing a comma is an OR over its pieces
// (`?status=open,done`, and `pad item list --status open,done` which sends
// exactly that). This is the ONE place a comma becomes an OR (BUG-3167):
// the store matches Fields exactly and takes the OR as FieldsAnyOf.
//
// DOCUMENTED LIMIT: over the query string, a literal value that itself
// contains a comma cannot be filtered for exactly, because the comma is
// the delimiter here. A repeated-parameter form (`?status=a&status=b`)
// would lift it, but an older server reads only the first value and
// silently NARROWS the result, so it was declined (BUG-3167 lead ruling;
// the census found no comma-containing option values).
fields := make(map[string]string)
anyOf := make(map[string][]string)
for key, values := range r.URL.Query() {
if knownParams[key] {
continue
}
// `unparented` is consumed as the structural boolean above only when its
// value is exactly "true" (see params.Unparented). Any other value —
// and, on a collection whose schema literally defines an `unparented`
// field, that's a legitimate field filter — falls through here so the
// structural keyword never silently shadows a real schema field.
if key == "unparented" && params.Unparented {
continue
}
if len(values) == 0 {
continue
}
if v := values[0]; strings.Contains(v, ",") {
pieces := strings.Split(v, ",")
for i := range pieces {
pieces[i] = strings.TrimSpace(pieces[i])
}
anyOf[key] = pieces
} else {
fields[key] = v
}
}
if len(fields) > 0 {
params.Fields = fields
}
if len(anyOf) > 0 {
params.FieldsAnyOf = anyOf
}
return params
}
// validateUnparentedListRequest keeps the boolean structural filter distinct
// from every supported parent alias. Checking the raw query makes the rule
// consistent even on a collection whose schema happens to define a real
// `parent` or `plan` field.
//
// This is the CANONICAL, load-bearing enforcement of parent/unparented mutual
// exclusivity — it guards every surface (CLI, MCP-exec, MCP-HTTP, direct API)
// because they all land here. The upstream copies are early-feedback only and
// intentionally redundant: cmd/pad/cmd_item.go's MarkFlagsMutuallyExclusive,
// internal/mcp/catalog_item.go::actionItemList, and
// internal/mcp/dispatch_http_routes.go::mapItemList. If this rule changes
// (e.g. a new parent alias), update this function first; keep the others in
// sync or drop them, but never let a caller reach the store without passing
// through here.
func validateUnparentedListRequest(r *http.Request, params models.ItemListParams) error {
if !params.Unparented {
return nil
}
for _, key := range []string{"parent_id", "parent", "plan"} {
if strings.TrimSpace(r.URL.Query().Get(key)) != "" {
return fmt.Errorf("unparented cannot be combined with %s", key)
}
}
return nil
}
// undeclaredFilterKeysHeader names the list filter keys that no schema in
// scope declares but that items in scope store (BUG-3480). The list body is a
// bare array, so the warning rides a header, as the import warnings do.
const undeclaredFilterKeysHeader = "X-Pad-Undeclared-Filter-Keys"
// checkListFilterKeys refuses a field filter the list cannot honour (BUG-3480).
// parseItemListParams reads every query parameter it does not know as a
// field filter, so a parameter the server never had (the web Library page's
// `all`, WebMCP's `collection`) answered 200 with an empty list and nothing
// said why. A key must now be declared by a schema in scope (the collection's,
// or for the workspace-wide list any visible collection's). A key no schema
// declares is still honoured when items in scope store it (the BUG-2850
// census found undeclared keys in live use) and is returned so the caller can
// warn; otherwise the request is refused. Call it after resolveParentFilter
// has consumed parent/plan and with params' scope and permission members set.
func (s *Server) checkListFilterKeys(workspaceID string, params models.ItemListParams, schemas []models.CollectionSchema, where string) (undeclared []string, err error) {
var keys []string
for k := range params.Fields {
keys = append(keys, k)
}
for k := range params.FieldsAnyOf {
keys = append(keys, k)
}
if len(keys) == 0 {
return nil, nil
}
// A bound before any store work (BUG-3703): each undeclared key can cost a
// probe of the list's scope.
if len(keys) > maxListFilterKeys {
return nil, listFilterRefusal(fmt.Sprintf("invalid list filter: %d field filters, at most %d", len(keys), maxListFilterKeys))
}
sort.Strings(keys)
var unknown []string
for _, k := range keys {
if !store.IsValidFieldKey(k) {
return nil, listFilterRefusal(fmt.Sprintf("invalid list filter %q: a field key may hold only letters, digits, '_' and '-'", k))
}
declared := false
for _, sc := range schemas {
if schemaHasField(sc, k) {
declared = true
break
}
}
if !declared {
unknown = append(unknown, k)
}
}
if len(unknown) == 0 {
return nil, nil
}
stored, err := s.store.StoredFieldKeys(workspaceID, params, unknown)
if err != nil {
return nil, err
}
for _, k := range unknown {
if !stored[k] {
return nil, listFilterRefusal(fmt.Sprintf("invalid list filter %q: it is not a list parameter, %s declares no such field, and no item stores one", k, where))
}
undeclared = append(undeclared, k)
}
return undeclared, nil
}
// Paging headers on the item lists (BUG-3704). The body stays a bare array,
// so the paging state rides in headers: has-more is "true" or "false" on
// every response, and next-offset is set only when there is more.
const (
itemListHasMoreHeader = "X-Pad-Has-More"
itemListNextOffsetHeader = "X-Pad-Next-Offset"
)
// listItemsPage runs the list one row past params.Limit (always set, by
// parseItemListParams) to learn whether more exist, trims that row, and sets
// the paging headers. The handlers filter nothing after the store call, so
// the extra row is exact.
func (s *Server) listItemsPage(w http.ResponseWriter, workspaceID string, params models.ItemListParams) ([]models.Item, error) {
limit := params.Limit
params.Limit = limit + 1
items, err := s.store.ListItems(workspaceID, params)
if err != nil {
return nil, err
}
if len(items) > limit {
items = items[:limit]
w.Header().Set(itemListHasMoreHeader, "true")
w.Header().Set(itemListNextOffsetHeader, strconv.Itoa(params.Offset+limit))
} else {
w.Header().Set(itemListHasMoreHeader, "false")
}
return items, nil
}
// parseListWindow reads ?limit= and ?offset= for a bounded list that is not an
// item list (BUG-3789, BUG-3790, BUG-3792), as parseItemListParams reads them
// for the item lists (BUG-3704): a missing, zero, negative or invalid limit is
// the maximum, an explicit one is clamped to it, and a bad offset is 0.
func parseListWindow(r *http.Request) (limit, offset int) {
limit = maxItemListQueryLimit
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l < limit {
limit = l
}
if o, err := strconv.Atoi(r.URL.Query().Get("offset")); err == nil && o > 0 {
offset = o
}
return limit, offset
}
// setListWindowHeaders takes rows read one past limit, trims the probe row and
// sets the paging headers the item lists set (BUG-3704).
func setListWindowHeaders[T any](w http.ResponseWriter, rows []T, limit, offset int) []T {
if len(rows) > limit {
w.Header().Set(itemListHasMoreHeader, "true")
w.Header().Set(itemListNextOffsetHeader, strconv.Itoa(offset+limit))
return rows[:limit]
}
w.Header().Set(itemListHasMoreHeader, "false")
return rows
}
// maxListFilterKeys bounds the field filters (field filters and any-of
// filters together) one list request may carry (BUG-3703).
const maxListFilterKeys = 50
// listFilterRefusal is checkListFilterKeys' 400; any other error it returns
// is a store failure. Both messages start "invalid list filter": local stdio
// MCP derives the error code from the CLI's stderr prose, and "invalid" is
// what classifies it validation_failed rather than a retryable server_error.
type listFilterRefusal string
func (e listFilterRefusal) Error() string { return string(e) }
// writeListFilterKeyError answers checkListFilterKeys' error: a refusal is a
// 400, anything else a store failure.
func writeListFilterKeyError(w http.ResponseWriter, err error) {
var refusal listFilterRefusal
if errors.As(err, &refusal) {
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
writeInternalError(w, err)
}
// handleListItemActivity returns the activity feed for a specific item.
func (s *Server) handleListItemActivity(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItemIncludeDeleted(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
params := models.ActivityListParams{
Action: r.URL.Query().Get("action"),
Actor: r.URL.Query().Get("actor"),
Source: r.URL.Query().Get("source"),
}
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil {
params.Limit = l
}
}
// This door never took offset, and it takes the keyset cursor its
// siblings do (BUG-2781): a cursor it ignored would answer every "next
// page" with the first page again.
var cursorOK bool
if params.Before, params.BeforeID, cursorOK = activityCursorFromQuery(w, r, 0); !cursorOK {
return
}
activities, err := s.store.ListDocumentActivity(item.ID, params)
if err != nil {
writeInternalError(w, err)
return
}
if activities == nil {
activities = []models.Activity{}
}
writeJSON(w, http.StatusOK, activities)
}
// requireRawParentInWorkspace refuses a top-level parent_id that does not name
// a live item in the workspace the route authorized (BUG-3346). The store
// writes it to items.parent_id as given and trusts its caller to have checked
// (CreateItemTx's TRUST BOUNDARY note), and this was the caller that did not:
// a foreign ID created a cross-workspace reference that could block the other
// workspace's purge. Empty (a clear) and absent pass. Same 400 whether the ID
// names nothing or another workspace's item, so it confirms nothing.
func (s *Server) requireRawParentInWorkspace(w http.ResponseWriter, workspaceID string, parentID *string) bool {
if parentID == nil || *parentID == "" {
return true
}
parent, err := s.store.GetItem(*parentID)
if err != nil {
writeInternalError(w, err)
return false
}
if parent == nil || parent.WorkspaceID != workspaceID {
writeError(w, http.StatusBadRequest, "validation_error", "parent_id does not name an item in this workspace")
return false
}
return true
}
// errItemChangedDuringMove is a move's precheck finding the item, or a
// collection its payload was built from, changed between the reads the
// payload was built from and the move's lock (BUG-3665).
var errItemChangedDuringMove = errors.New("item changed during move")
// moveInputsUnchanged is a move's under-lock precheck. A move builds its
// payload from reads taken before it takes its lock (BUG-3665). Every input to
// it is re-read under the lock, and a move whose inputs changed is refused
// (errItemChangedDuringMove, or *moveUniqueConflictError); the caller re-reads
// and moves again. Every move checks this, a forced one included (force skips
// only the open-children guard, which runs last as guard when non-nil). The
// inputs:
// - the item's collection and fields (codex r1);
// - the source collection's schema and settings, which the migration and
// the close-state check read (codex r2);
// - the target collection's schema and settings, likewise (the store also
// re-validates the fields against the target schema under the lock,
// BUG-3407);
// - the target's unique-field holders, re-queried on the lock's
// transaction.
//
// Without it a write landing in between was overwritten by fields migrated
// from the older body, and the move's history and events described that
// older body. The single move and the bulk move both use it (BUG-3813: the
// bulk move had none of it).
func (s *Server) moveInputsUnchanged(item *models.Item, sourceColl, targetColl *models.Collection, targetFields []models.FieldDef, fields map[string]any, guard func(tx *sql.Tx, existing *models.Item) error) func(tx *sql.Tx, existing *models.Item) error {
sameCollection := func(tx *sql.Tx, read *models.Collection) error {
cur, err := s.store.GetCollectionQ(tx, read.ID)
if err != nil {
return err
}
if cur == nil || cur.Schema != read.Schema || cur.Settings != read.Settings {
return errItemChangedDuringMove
}
return nil
}
return func(tx *sql.Tx, existing *models.Item) error {
if existing.CollectionID != item.CollectionID || existing.Fields != item.Fields {
return errItemChangedDuringMove
}
if err := sameCollection(tx, sourceColl); err != nil {
return err
}
if err := sameCollection(tx, targetColl); err != nil {
return err
}
if conflicts, err := s.store.UniqueFieldConflictsQ(tx, targetColl.ID, item.ID, targetFields, fields); err != nil {
return err
} else if len(conflicts) > 0 {
return &moveUniqueConflictError{msg: store.UniqueFieldConflictsMessage(conflicts)}
}
if guard != nil {
return guard(tx, existing)
}
return nil
}
}
// moveUniqueConflictError is the under-lock re-check of the target's unique
// fields finding a holder that appeared after the handler's own check.
type moveUniqueConflictError struct{ msg string }
func (e *moveUniqueConflictError) Error() string { return e.msg }