mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
feat(server): refuse to mark item terminal while it has open children (IDEA-1494) (#571)
* feat(server): refuse to mark item terminal while it has open children (IDEA-1494)
Server-side guard inside handleUpdateItem that rejects a non-terminal →
terminal done-field transition when the item still has at least one
non-terminal child. Returns HTTP 409 with code=open_children plus a
structured details payload listing each blocking child's
{ref, title, status, collection_slug} so MCP-driven agents can
self-recover (ship the listed children, then retry) and the CLI can
render the same list verbatim.
Escape hatch: `--force` on `pad item update` / `pad item bulk-update`
and `force: true` on the MCP pad_item.action: update / bulk-update
inputs both forward into the same ItemUpdate.Force transport field
the handler consumes before any store mutation.
Trigger conditions are tight: the PATCH must change the done-field key
(resolved via TerminalValuesForDoneField against the parent's schema +
settings) AND the new value must be terminal AND the current value
must NOT already be terminal. Terminal → terminal and no-op terminal
transitions bypass the guard; only entering the terminal set is gated.
Per-child evaluation uses the child's own collection schema so
hierarchical workspaces with custom typed collections work without
extra plumbing.
Tests cover: rejection with one open child (with mutation-safety
assertion on the parent), no children, all-terminal children, --force
override, no-op terminal → terminal, terminal → terminal,
non-terminal → non-terminal, custom collection terminal_options
honored, and a parent task (not a plan) — IDEA-1494 optional extra #3.
MCP coverage asserts --force round-trips through both ExecDispatcher
and HTTPHandlerDispatcher and is omitted when force=false.
* fix(server): open-children guard round 2 — visibility, MCP pass-through, TOCTOU (IDEA-1494)
Three Codex round-1 issues, each fixed with the recommended shape:
P1 — visibility leak. The 409 response previously listed every blocking
child by ref/title/status, including children in collections the caller
couldn't see. The INVARIANT still evaluates against ALL children (it's a
data-integrity gate — a restricted user must not be able to close a
parent whose blockers they can't see), but the response payload now
filters to caller-visible children only. Hidden blockers surface as a
new `details.hidden_blocker_count` field plus an alternate human message
when every blocker is hidden ("blocked by N open children you don't
have access to"). Mirrors the visibility helpers (`visibleCollectionIDs`
+ `isItemVisibleToGuest`) used by the per-parent progress endpoint so
the two paths can't drift.
P2 — MCP code/details pass-through. The HTTP classifier was collapsing
409 into the generic `conflict` code and dropping `details`; the stdio
classifier was matching the human "cannot " message against the
validation regex and surfacing `validation_failed`. Both now surface
`open_children` with the structured details intact:
- HTTP: classifyHTTPStatusKind's 409 branch extracts the upstream
code; any non-empty, non-"conflict" code is passed through with
its `details` RawMessage. Generalizes beyond open_children — any
future structured 409 from a handler gets the same treatment.
- Stdio: the CLI writes a `pad-error: {json}\n` marker line on
stderr before the human-readable block (single source of truth for
both views), and classifyExecError detects the marker and lifts
the envelope verbatim. Marker is duplicated as a const between
internal/cli and internal/mcp to avoid pulling the cli package
into the classifier just for one string.
A new ErrOpenChildren error code constant + `Details json.RawMessage`
field on ErrorPayload back the wire shape.
P2 — TOCTOU. The guard previously ran in the handler before the store
transaction began; a concurrent child insert / child status flip could
slip between the children-list read and the parent's UPDATE. Fix:
- New `Store.UpdateItemWithPreCheck(id, input, precheck)` runs the
caller's invariant check inside the same tx, after acquiring the
workspace seq lock AND a new parent-children advisory lock keyed
on the parent ID. UpdateItem is now a thin wrapper passing nil.
- Every UpdateItem unconditionally acquires the parent-children
advisory lock for its own parent (if any) AND for itself-as-parent,
in a fixed order (parent first) so two updaters touching the same
parent always grab that key before the more-specific one — no
AB/BA deadlock.
- New `GetChildItemsTx` reads via the caller's tx; on Postgres the
advisory lock provides the snapshot guarantee (DISTINCT precludes
`FOR UPDATE`), on SQLite the global BEGIN IMMEDIATE write lock
serializes all writers.
- Handler now passes a precheck closure into UpdateItemWithPreCheck
at all three call sites (collab-snapshot path, applier-direct-write
path, main path). The guard's openChildrenGuardError sentinel is
unwrapped after each call so the 409 surfaces cleanly.
Tests:
- TestOpenChildrenGuard_VisibilitySanitizesPayload — restricted
editor sees parent + visible child, hidden child contributes to
hidden_blocker_count, no leak of ref/title/slug.
- TestOpenChildrenGuard_AllBlockersHiddenSurfaceGenericMessage —
open_children=[], hidden_blocker_count>0, message mentions "you
don't have access to."
- TestOpenChildrenGuard_TOCTOURace — 8 iterations of a child-flip
racing a parent-terminal update; asserts the forbidden outcome
(parent=completed AND child=open) never occurs.
- TestClassifyHTTPStatus_OpenChildrenPreservesCodeAndDetails +
inverse generic-409 test.
- TestClassifyExecError_OpenChildrenMarkerLiftsStructuredPayload +
no-marker-falls-through inverse.
* fix(server): open-children guard round 3 — 7 Codex findings closed (IDEA-1494)
P1 — visibility fail-closed. The handler was swallowing
visibleCollectionIDs errors, leaving visIDs==nil which the guard
treats as unrestricted, leaking hidden-child metadata. Now surfaces
the error as 500 BEFORE installing the precheck. Test:
TestOpenChildrenGuard_VisibilityLookupErrorFailsClosed closes the
store DB and asserts no 409+children leak.
P1 — link mutations acquire the advisory lock. SetParentLink,
ClearParentLink, CreateItemLink (when link_type ∈ childLinkTypes via
new isChildLinkType helper), DeleteItemLink (same condition), and
RestoreItem now take `pad:parent-children:<id>` in canonical sorted
order via new AcquireParentChildrenLocks helper. SetParentLink locks
BOTH old and new parents (re-parenting case). Race test
TestOpenChildrenGuard_LinkMutationRace asserts the forbidden
"link-committed-before-parent-flip AND parent flip succeeded" never
occurs by comparing link.created_at to parent.updated_at. Documented
semantics: status-wins + link-after-commit is legal under the
invariant "no open children EXIST AT THE MOMENT of transition" —
the post-condition variant ("no open child may EVER attach to a
terminal parent") is intentionally deferred.
P1 — MoveItem bypass closed. New MoveItemWithPreCheck mirrors
UpdateItemWithPreCheck — acquires workspace seq lock + parent-children
locks, re-reads in tx, runs caller precheck. handleMoveItem builds
the same guard closure using the DESTINATION schema for done-field
resolution (conservative — honors the schema the item moves INTO).
CLI gains `pad item move --force`, client gains MoveItemWithForce
that appends `?force=true` to the move endpoint. MCP catalog +
mapItemMove forward `force` through the route mapper. Tests:
TestOpenChildrenGuard_MoveItem_RejectsTerminalWithOpenChildren and
…_ForceOverrides.
P2 — pre-tx field-read TOCTOU. UpdateItemWithPreCheck and
MoveItemWithPreCheck now re-read the item via new getItemTx INSIDE
the tx (after locks) and pass that fresh snapshot to the precheck
closure; the precheck classifies the transition against the in-tx
view, not the handler-side pre-tx capture. Handler precheck closure
swaps `currentFieldsJS` from the in-tx snapshot. Test:
TestOpenChildrenGuard_PrecheckReadsInTxSnapshot stages a between-load
status mutation and asserts the precheck observes the post-mutation
fields.
P2 — bulk-update carries structured errors. cmd/pad/main.go's
updateFailure struct extended with Code + Details
(json.RawMessage). When client.UpdateItem returns *cli.APIError, the
row preserves the structured envelope. Human-text output also
renders the open-children list inline. Chose JSON-envelope route
over per-row stderr markers because bulk-update already produces a
structured envelope and ExecDispatcher returns stdout verbatim on
exit-0 — no classifier change needed. Test:
TestBulkUpdateStructuredFailuresCarryOpenChildrenDetails confirms
the wire shape the CLI lifts.
P3 — marker hardening. Marker bumped to versioned form
`pad-structured-error/v1:` (was `pad-error:`). cli.StructuredErrorMarker
+ mcp.structuredErrorMarker kept in lockstep with cross-references.
mcp.allowedStructuredErrorCodes whitelists known codes (currently
just open_children); unknown codes fall back to regex classification.
Marker must start the line after whitespace trim (embedded markers
ignored). Last-marker-wins to defeat pre-emption attacks. Tests:
TestClassifyExecError_{UnknownStructuredCode,OldMarkerVersion,
MarkerEmbeddedMidLine,LastMarker}.
P3 — soft-deleted collection schemas honored. New GetCollectionAnyState
mirrors childrenDoneFiltersForParent's inclusion rule; guard uses it
so a child still attached to a soft-deleted collection is evaluated
against ITS schema (custom terminal_options) instead of the default-
status fallback (which would mis-classify and false-block). Test:
TestOpenChildrenGuard_SoftDeletedCollectionSchemaHonored seeds a
custom collection, soft-deletes it while a child remains, and
asserts the terminal status is correctly recognized.
Comprehensive store-mutation audit results recorded in the PR
description (every method touching items.fields / items.collection_id
or item_links).
* fix(server): open-children guard round 4 — multi-parent locks, enum parity, PATCH atomicity (IDEA-1494)
Four Codex round-3 (blast-radius lens) findings, each fixed with the
recommended shape.
P1 — multi-parent lock set. acquireParentChildrenLocksForUpdate and
RestoreItem previously used `LIMIT 1` against item_links, so a child
with BOTH a `parent` link to P1 AND an `implements` link to P2 only
locked one of them. The other parent's open-children precheck could
race against the child's status flip and miss it.
Fix: new listParentChildLockKeys helper runs the same query
GetChildItems' inclusion rule uses (childLinkTypes), returns ALL
distinct parent target_ids, and feeds them into the canonical
multi-lock helper. Both UpdateItemWithPreCheck and RestoreItem now
acquire locks on {self} ∪ {all-parents-via-childLinkTypes}. Test:
TestOpenChildrenGuard_MultiParentChildLocksAll races a child status
flip against terminal-updates on both parents simultaneously.
P2 — lock-order asymmetry. The pre-fix codebase had multiple lock-
acquisition shapes: parent-then-self in acquireParentChildrenLocksForUpdate,
single-key in RestoreItem / CreateItemLink / DeleteItemLink /
ClearParentLink, and a sorted multi-key in SetParentLink. Two
concurrent callers using different ad-hoc orderings could AB/BA
deadlock.
Fix: removed the per-call-site AcquireParentChildrenLock helper
entirely. Every site now goes through AcquireParentChildrenLocks
(the canonical sorted multi-lock helper) — including ones that need
only one ID (the variadic call still sorts a one-element slice).
The helper's doc comment explicitly states the contract: "Ad-hoc
single-key acquisition outside this helper is FORBIDDEN — two call
sites taking distinct keys in different orders WILL deadlock."
Test: TestOpenChildrenGuard_NoDeadlockUnderReverseOrderConcurrency
runs reverse-order re-parents with a 5-second timeout; assertion
fails on hang.
P2 — HTTP/stdio code-surface parity. Round 2's HTTP pass-through
("any non-conflict upstream code") silently widened the ErrorCode
enum beyond stdio's allow-list (`open_children` only). Agents saw
different code surfaces depending on which dispatcher delivered
the response.
Fix: HTTP 409 branch in classifyHTTPStatusKind now consults the
same allowedStructuredErrorCodes whitelist stdio does. Codes
outside the set collapse to ErrConflict (no details), matching
what stdio does for an unknown-code structured marker. Doc on
allowedStructuredErrorCodes updated to make the dual-consumer
contract explicit: "Adding a new structured code is a TWO-WAY
change." Tests:
TestClassifyHTTPStatus_UnknownConflictCodeFallsBackToErrConflict
and TestStructuredErrorCodeParityAcrossTransports.
P3 — PATCH atomicity. A combined PATCH with `parent` + `status=terminal`
on an item with open children used to commit the parent-link change
INLINE (before the guard ran) and then reject the field write.
Caller saw 409 but the parent had already moved.
Fix: parent-link mutation is now DEFERRED — captured into outer-
scope vars during fields validation, executed AFTER
UpdateItemWithPreCheck succeeds. A guard rejection returns before
the link write block, so on rejection the link is untouched.
Documented choice: "reorder, don't tx-wrap" — wrapping SetParentLink
into the same store tx would require threading a *sql.Tx through
the SetParentLink API (which is also called from the
handler_item_links path); reordering is the smaller surgery and
gives the correct outcome on the failure direction. A residual
window remains in the OTHER direction (field write commits, link
write fails) — not made worse by the reorder, and called out
inline for a future tx-wrap pass.
Test: TestOpenChildrenGuard_PatchAtomicRejectionPreservesParentLink
sets up target → oldParent → openChild, sends PATCH {parent=newParent,
status=completed}, asserts 409 AND target.parent_link still points
at oldParent.
* fix(server): open-children guard — emit details.open_children as [] not null on hidden-only rejection (IDEA-1494)
This commit is contained in:
+72
-4
@@ -3392,6 +3392,7 @@ func updateCmd() *cobra.Command {
|
||||
tags string
|
||||
fieldFlags []string
|
||||
comment string
|
||||
force bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -3442,6 +3443,9 @@ Examples:
|
||||
if comment != "" {
|
||||
input.Comment = &comment
|
||||
}
|
||||
if force {
|
||||
input.Force = true
|
||||
}
|
||||
|
||||
// Merge field changes with existing fields
|
||||
parentRef := parentFlag
|
||||
@@ -3529,6 +3533,19 @@ Examples:
|
||||
|
||||
updated, err := client.UpdateItem(ws, slug, input)
|
||||
if err != nil {
|
||||
// IDEA-1494: render the open-children list when the
|
||||
// server rejected the transition because the item has
|
||||
// non-terminal children. The detailed list comes from
|
||||
// the same structured payload MCP clients consume so
|
||||
// the human and machine views agree.
|
||||
if apiErr, ok := err.(*cli.APIError); ok {
|
||||
if oc := apiErr.AsOpenChildren(); oc != nil {
|
||||
cli.WriteOpenChildrenError(os.Stderr, apiErr, oc)
|
||||
// Return a bare error so cobra exits non-zero
|
||||
// without re-printing the (now-rendered) details.
|
||||
return fmt.Errorf("update rejected: open children present")
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3561,6 +3578,7 @@ Examples:
|
||||
cmd.Flags().StringVar(&tags, "tags", "", "update tags (JSON array)")
|
||||
cmd.Flags().StringArrayVarP(&fieldFlags, "field", "f", nil, "set arbitrary field (repeatable): --field key=value")
|
||||
cmd.Flags().StringVar(&comment, "comment", "", "attach a comment explaining this update (e.g. why status changed)")
|
||||
cmd.Flags().BoolVar(&force, "force", false, "override the open-children guard (allow marking the item terminal even if children are non-terminal)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -3622,6 +3640,7 @@ func deleteCmd() *cobra.Command {
|
||||
// --- move ---
|
||||
|
||||
func moveCmd() *cobra.Command {
|
||||
var force bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "move <ref> <target-collection>",
|
||||
Short: "Move an item to a different collection",
|
||||
@@ -3659,8 +3678,18 @@ Examples:
|
||||
input["field_overrides"] = overrides
|
||||
}
|
||||
|
||||
moved, err := client.MoveItem(ws, args[0], input)
|
||||
moved, err := client.MoveItemWithForce(ws, args[0], input, force)
|
||||
if err != nil {
|
||||
// IDEA-1494 R3 P1: render the open-children rejection
|
||||
// the same way the regular update path does, so
|
||||
// `pad item move ... --field status=completed` against
|
||||
// a plan with open children fails informatively.
|
||||
if apiErr, ok := err.(*cli.APIError); ok {
|
||||
if oc := apiErr.AsOpenChildren(); oc != nil {
|
||||
cli.WriteOpenChildrenError(os.Stderr, apiErr, oc)
|
||||
return fmt.Errorf("move rejected: open children present")
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3669,6 +3698,7 @@ Examples:
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringArray("field", nil, "set field values in target collection (key=value)")
|
||||
cmd.Flags().BoolVar(&force, "force", false, "override the open-children guard when the move would write a terminal done-field value")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -6478,6 +6508,7 @@ func bulkUpdateCmd() *cobra.Command {
|
||||
var (
|
||||
status string
|
||||
priority string
|
||||
force bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -6511,9 +6542,17 @@ Examples:
|
||||
Ref string `json:"ref"`
|
||||
Applied map[string]any `json:"applied"`
|
||||
}
|
||||
// updateFailure carries the structured server error per row
|
||||
// so MCP-driven agents see code + details (IDEA-1494 R3 P2).
|
||||
// `Error` stays as the human-readable message for backward
|
||||
// compatibility with non-MCP CLI consumers; `Code` and
|
||||
// `Details` populate when the server returned a structured
|
||||
// envelope (currently: only open_children rejections).
|
||||
type updateFailure struct {
|
||||
Ref string `json:"ref"`
|
||||
Error string `json:"error"`
|
||||
Ref string `json:"ref"`
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Details json.RawMessage `json:"details,omitempty"`
|
||||
}
|
||||
updated := make([]updateResult, 0, len(args))
|
||||
failed := make([]updateFailure, 0)
|
||||
@@ -6553,14 +6592,42 @@ Examples:
|
||||
|
||||
input := models.ItemUpdate{
|
||||
Fields: &fieldsStr,
|
||||
Force: force,
|
||||
}
|
||||
|
||||
_, err = client.UpdateItem(ws, slug, input)
|
||||
if err != nil {
|
||||
// IDEA-1494 R3 P2: preserve the structured server
|
||||
// error per row so the JSON envelope (and any
|
||||
// MCP-driven agent reading it) sees code +
|
||||
// details — not just a flattened string. The
|
||||
// human text output continues to show the bare
|
||||
// message.
|
||||
row := updateFailure{Ref: slug, Error: err.Error()}
|
||||
if apiErr, ok := err.(*cli.APIError); ok && apiErr.Code != "" {
|
||||
row.Code = apiErr.Code
|
||||
if len(apiErr.Details) > 0 {
|
||||
row.Details = apiErr.Details
|
||||
}
|
||||
}
|
||||
if formatFlag != "json" {
|
||||
fmt.Printf(" %s %s — %s\n", red.Sprint("✗"), slug, err)
|
||||
// For open_children rejections also render the
|
||||
// blocking-child list inline so the human
|
||||
// reader doesn't have to switch to --format
|
||||
// json to see what blocked.
|
||||
if apiErr, ok := err.(*cli.APIError); ok {
|
||||
if oc := apiErr.AsOpenChildren(); oc != nil {
|
||||
for _, c := range oc.OpenChildren {
|
||||
fmt.Printf(" %s — %s (status=%s)\n", c.Ref, c.Title, c.Status)
|
||||
}
|
||||
if oc.HiddenBlockerCount > 0 {
|
||||
fmt.Printf(" (+%d hidden blocker(s) you don't have access to)\n", oc.HiddenBlockerCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failed = append(failed, updateFailure{Ref: slug, Error: err.Error()})
|
||||
failed = append(failed, row)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -6594,6 +6661,7 @@ Examples:
|
||||
|
||||
cmd.Flags().StringVar(&status, "status", "", "set status for all items")
|
||||
cmd.Flags().StringVar(&priority, "priority", "", "set priority for all items")
|
||||
cmd.Flags().BoolVar(&force, "force", false, "override the open-children guard (allow marking items terminal even if children are non-terminal)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
+119
-3
@@ -202,8 +202,21 @@ func (c *Client) ListStarredItems(wsSlug string, includeTerminal bool) ([]models
|
||||
}
|
||||
|
||||
func (c *Client) MoveItem(wsSlug, itemSlug string, input map[string]any) (*models.Item, error) {
|
||||
return c.MoveItemWithForce(wsSlug, itemSlug, input, false)
|
||||
}
|
||||
|
||||
// MoveItemWithForce is the open-children-guard-aware variant of
|
||||
// MoveItem (IDEA-1494 R3 P1). When `force` is true, the URL gets a
|
||||
// `?force=true` query so the server-side move handler skips the guard
|
||||
// and still records the collection + fields change. Same escape-hatch
|
||||
// semantics as `pad item update --force`.
|
||||
func (c *Client) MoveItemWithForce(wsSlug, itemSlug string, input map[string]any, force bool) (*models.Item, error) {
|
||||
var result models.Item
|
||||
return &result, c.post("/workspaces/"+wsSlug+"/items/"+itemSlug+"/move", input, &result)
|
||||
path := "/workspaces/" + wsSlug + "/items/" + itemSlug + "/move"
|
||||
if force {
|
||||
path += "?force=true"
|
||||
}
|
||||
return &result, c.post(path, input, &result)
|
||||
}
|
||||
|
||||
// --- Links ---
|
||||
@@ -705,14 +718,117 @@ func (c *Client) GetAuditLog(params models.AuditLogParams) ([]models.Activity, e
|
||||
// --- HTTP helpers ---
|
||||
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details json.RawMessage `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// OpenChildEntry mirrors the server-side openChildEntry payload returned
|
||||
// inside APIError.Details when Code == "open_children" (IDEA-1494). The
|
||||
// CLI renders its human error list from these entries; MCP-driven agents
|
||||
// can introspect the same data to self-recover.
|
||||
type OpenChildEntry struct {
|
||||
Ref string `json:"ref"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
CollectionSlug string `json:"collection_slug"`
|
||||
}
|
||||
|
||||
// OpenChildrenDetails is the parsed shape of APIError.Details when
|
||||
// Code == "open_children".
|
||||
type OpenChildrenDetails struct {
|
||||
OpenChildren []OpenChildEntry `json:"open_children"`
|
||||
HiddenBlockerCount int `json:"hidden_blocker_count"`
|
||||
DoneField string `json:"done_field"`
|
||||
AttemptedValue string `json:"attempted_value"`
|
||||
}
|
||||
|
||||
// AsOpenChildren returns the parsed open-children details when this
|
||||
// APIError carries them, or nil otherwise. Returns nil for any error
|
||||
// other than "open_children" so callers can branch cleanly.
|
||||
func (e *APIError) AsOpenChildren() *OpenChildrenDetails {
|
||||
if e == nil || e.Code != "open_children" || len(e.Details) == 0 {
|
||||
return nil
|
||||
}
|
||||
var d OpenChildrenDetails
|
||||
if err := json.Unmarshal(e.Details, &d); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &d
|
||||
}
|
||||
|
||||
// StructuredErrorMarker is the versioned line prefix the CLI writes
|
||||
// to stderr when surfacing a structured error (currently: IDEA-1494's
|
||||
// open-children rejection). The JSON line that follows carries the
|
||||
// full server-style envelope (code / message / details) so a
|
||||
// downstream consumer — the stdio MCP dispatcher's classifyExecError
|
||||
// in particular — can detect the rejection and lift the structured
|
||||
// payload without parsing free-form human text.
|
||||
//
|
||||
// Versioned (Codex round-3 P3) so future evolutions of the wire shape
|
||||
// don't silently break older parsers — when the payload contract
|
||||
// changes incompatibly, bump to `pad-structured-error/v2:` and have
|
||||
// the classifier accept both during the transition. The version token
|
||||
// is parsed (not just matched as a literal prefix) so older v1-only
|
||||
// parsers cleanly ignore unknown versions.
|
||||
//
|
||||
// IMPORTANT: keep in lockstep with mcp.structuredErrorMarker / the
|
||||
// allow-list of structured codes in mcp.allowedStructuredErrorCodes.
|
||||
// A change here REQUIRES a corresponding change in
|
||||
// internal/mcp/errors.go.
|
||||
const StructuredErrorMarker = "pad-structured-error/v1: "
|
||||
|
||||
// OpenChildrenErrorMarker is the pre-round-3 marker, retained as a
|
||||
// deprecated alias for any out-of-tree consumer that may have hard-
|
||||
// coded it. New code MUST use StructuredErrorMarker.
|
||||
//
|
||||
// Deprecated: use StructuredErrorMarker.
|
||||
const OpenChildrenErrorMarker = StructuredErrorMarker
|
||||
|
||||
// WriteOpenChildrenError formats an open-children rejection to w in
|
||||
// the canonical two-track shape the project guarantees (IDEA-1494 R2):
|
||||
//
|
||||
// 1. A single `pad-error: {json}\n` line carrying the full structured
|
||||
// payload — consumed by the MCP stdio classifier and anyone else
|
||||
// wanting to introspect the rejection programmatically.
|
||||
// 2. Human-readable lines: the message, the per-child list (rendered
|
||||
// from the SAME details struct the JSON line carries — single
|
||||
// source of truth for both views), the hidden-count tag when
|
||||
// applicable, and the `Pass --force to override` reminder.
|
||||
//
|
||||
// Order matters: machine line first so a consumer that reads stderr
|
||||
// line-by-line can dispatch on the first line without buffering all
|
||||
// of it. Callers should write nothing else between the marker line
|
||||
// and the human block.
|
||||
func WriteOpenChildrenError(w io.Writer, apiErr *APIError, oc *OpenChildrenDetails) {
|
||||
envelope := map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": apiErr.Code,
|
||||
"message": apiErr.Message,
|
||||
"details": oc,
|
||||
},
|
||||
}
|
||||
if data, err := json.Marshal(envelope); err == nil {
|
||||
fmt.Fprintln(w, StructuredErrorMarker+string(data))
|
||||
}
|
||||
fmt.Fprintln(w, apiErr.Message)
|
||||
for _, c := range oc.OpenChildren {
|
||||
fmt.Fprintf(w, " %s — %s (status=%s)\n", c.Ref, c.Title, c.Status)
|
||||
}
|
||||
if oc.HiddenBlockerCount > 0 {
|
||||
noun := "child"
|
||||
if oc.HiddenBlockerCount != 1 {
|
||||
noun = "children"
|
||||
}
|
||||
fmt.Fprintf(w, " (+%d hidden %s you don't have access to)\n", oc.HiddenBlockerCount, noun)
|
||||
}
|
||||
fmt.Fprintln(w, "Pass --force to override.")
|
||||
}
|
||||
|
||||
// --- Attachments ---
|
||||
//
|
||||
// AttachmentUploadResult mirrors the JSON returned by
|
||||
|
||||
@@ -145,6 +145,17 @@ var padItemSchemaParams = []ParamDef{
|
||||
{Name: "reply_to", Type: "string", Description: "Parent comment ID for threading replies. Optional for: comment."},
|
||||
{Name: "comment", Type: "string", Description: "Audit comment explaining the change. Optional for: update."},
|
||||
|
||||
// ── Guard override ── (IDEA-1494)
|
||||
// update/bulk-update reject non-terminal → terminal done-field
|
||||
// transitions while the item still has open (non-terminal)
|
||||
// children, surfacing a 409 with code=open_children plus a
|
||||
// machine-readable details.open_children array (one entry per
|
||||
// blocking child: {ref, title, status, collection_slug}). Setting
|
||||
// force=true skips the guard and still records the transition.
|
||||
// The same flag exists on `pad item update --force` so the CLI
|
||||
// and MCP escape hatches are identical.
|
||||
{Name: "force", Type: "bool", Description: "Override the open-children guard. Optional for: update, bulk-update, move. When the server returns code=open_children, details.open_children lists the blocking child refs so an agent can ship them and retry — or set force=true if the children should be intentionally orphaned."},
|
||||
|
||||
// ── Notes / decisions ──
|
||||
{Name: "summary", Type: "string", Description: "Short note headline. Required for: note."},
|
||||
{Name: "details", Type: "string", Description: "Long-form note body. Optional for: note."},
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package mcp
|
||||
|
||||
// IDEA-1494 — MCP-level coverage for the `force` parameter on
|
||||
// pad_item.action: update / bulk-update. Confirms the catalog's force
|
||||
// param translates into the `--force` CLI flag on the dispatched
|
||||
// command so the open-children guard's escape hatch is identical on
|
||||
// stdio MCP (ExecDispatcher) and the legacy CLI surface.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPadItemUpdate_ForcePassesThroughToCLI(t *testing.T) {
|
||||
disp := &fakeDispatcher{}
|
||||
env := ActionEnv{
|
||||
Doc: liveCmdhelpDoc(t),
|
||||
Workspace: NewWorkspaceState("docapp"),
|
||||
Dispatcher: disp,
|
||||
}
|
||||
|
||||
handler, ok := padItemTool.Actions["update"]
|
||||
if !ok {
|
||||
t.Fatal("pad_item.action: update is not registered")
|
||||
}
|
||||
|
||||
if _, err := handler(context.Background(), map[string]any{
|
||||
"ref": "PLAN-5",
|
||||
"status": "completed",
|
||||
"force": true,
|
||||
}, env); err != nil {
|
||||
t.Fatalf("dispatch error: %v", err)
|
||||
}
|
||||
if !equalStrings(disp.gotPath, []string{"item", "update"}) {
|
||||
t.Fatalf("cmdPath = %v, want [item update]", disp.gotPath)
|
||||
}
|
||||
joined := strings.Join(disp.gotArgs, " ")
|
||||
if !strings.Contains(joined, "--force") {
|
||||
t.Errorf("cliArgs should contain '--force' when force=true; got %q", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "--status completed") {
|
||||
t.Errorf("cliArgs should preserve other flags; got %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPadItemUpdate_ForceFalse_Omitted(t *testing.T) {
|
||||
disp := &fakeDispatcher{}
|
||||
env := ActionEnv{
|
||||
Doc: liveCmdhelpDoc(t),
|
||||
Workspace: NewWorkspaceState("docapp"),
|
||||
Dispatcher: disp,
|
||||
}
|
||||
|
||||
handler := padItemTool.Actions["update"]
|
||||
if _, err := handler(context.Background(), map[string]any{
|
||||
"ref": "PLAN-5",
|
||||
"status": "completed",
|
||||
"force": false,
|
||||
}, env); err != nil {
|
||||
t.Fatalf("dispatch error: %v", err)
|
||||
}
|
||||
joined := strings.Join(disp.gotArgs, " ")
|
||||
if strings.Contains(joined, "--force") {
|
||||
t.Errorf("cliArgs should NOT contain '--force' when force=false; got %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPadItemBulkUpdate_ForcePassesThroughToCLI(t *testing.T) {
|
||||
disp := &fakeDispatcher{}
|
||||
env := ActionEnv{
|
||||
Doc: liveCmdhelpDoc(t),
|
||||
Workspace: NewWorkspaceState("docapp"),
|
||||
Dispatcher: disp,
|
||||
}
|
||||
|
||||
handler := padItemTool.Actions["bulk-update"]
|
||||
if _, err := handler(context.Background(), map[string]any{
|
||||
"refs": []any{"TASK-1", "TASK-2"},
|
||||
"status": "done",
|
||||
"force": true,
|
||||
}, env); err != nil {
|
||||
t.Fatalf("dispatch error: %v", err)
|
||||
}
|
||||
joined := strings.Join(disp.gotArgs, " ")
|
||||
if !strings.Contains(joined, "--force") {
|
||||
t.Errorf("cliArgs should contain '--force' for bulk-update force=true; got %q", joined)
|
||||
}
|
||||
}
|
||||
@@ -463,11 +463,16 @@ func liveCmdhelpDoc(t *testing.T) *cmdhelp.Document {
|
||||
"item update": {
|
||||
Summary: "update item",
|
||||
Args: mkArgs("ref"),
|
||||
Flags: mkFlags(
|
||||
"workspace", "assign", "category", "comment", "content",
|
||||
"field", "parent", "priority", "role", "status",
|
||||
"tags", "title",
|
||||
),
|
||||
Flags: func() map[string]cmdhelp.Flag {
|
||||
f := mkFlags(
|
||||
"workspace", "assign", "category", "comment", "content",
|
||||
"field", "parent", "priority", "role", "status",
|
||||
"tags", "title",
|
||||
)
|
||||
// IDEA-1494: --force bypasses the open-children guard.
|
||||
f["force"] = cmdhelp.Flag{Type: "bool"}
|
||||
return f
|
||||
}(),
|
||||
},
|
||||
"item delete": {
|
||||
Summary: "delete item",
|
||||
@@ -502,6 +507,10 @@ func liveCmdhelpDoc(t *testing.T) *cmdhelp.Document {
|
||||
Flags: map[string]cmdhelp.Flag{
|
||||
"workspace": {Type: "string"},
|
||||
"field": {Type: "[]string", Repeatable: true},
|
||||
// IDEA-1494 R3 P1: move now honors the same
|
||||
// open-children guard override the update path
|
||||
// does.
|
||||
"force": {Type: "bool"},
|
||||
},
|
||||
},
|
||||
"item deps": {
|
||||
@@ -539,7 +548,11 @@ func liveCmdhelpDoc(t *testing.T) *cmdhelp.Document {
|
||||
"item bulk-update": {
|
||||
Summary: "bulk update",
|
||||
Args: []cmdhelp.Arg{{Name: "ref", Required: true, Repeatable: true}},
|
||||
Flags: mkFlags("workspace", "priority", "status"),
|
||||
Flags: func() map[string]cmdhelp.Flag {
|
||||
f := mkFlags("workspace", "priority", "status")
|
||||
f["force"] = cmdhelp.Flag{Type: "bool"}
|
||||
return f
|
||||
}(),
|
||||
},
|
||||
"item note": {
|
||||
Summary: "add note",
|
||||
|
||||
@@ -349,6 +349,14 @@ func (d *HTTPHandlerDispatcher) dispatchItemUpdate(
|
||||
if b, ok := input["pinned"].(bool); ok {
|
||||
payload["pinned"] = b
|
||||
}
|
||||
// IDEA-1494: forward the open-children guard override. When set,
|
||||
// the server-side handler skips the guard and still records the
|
||||
// status transition. Same wire shape the CLI uses (`force: true`
|
||||
// on the ItemUpdate body), so the HTTP dispatcher and ExecDispatcher
|
||||
// paths share one contract.
|
||||
if b, ok := input["force"].(bool); ok && b {
|
||||
payload["force"] = true
|
||||
}
|
||||
|
||||
// Field merging — the actual reason this command needs a custom
|
||||
// dispatcher rather than a routeSpec entry. Match the CLI's
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package mcp
|
||||
|
||||
// IDEA-1494 — confirm the HTTP dispatcher forwards the open-children
|
||||
// guard override (`force`) into the PATCH body. Matches the wire
|
||||
// contract handleUpdateItem reads (`force: true` at the top level of
|
||||
// the ItemUpdate JSON body).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
|
||||
func TestDispatchItemUpdate_ForwardsForceFlag(t *testing.T) {
|
||||
captured := newRequestCapture()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/workspaces/docapp/items/PLAN-5", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ref":"PLAN-5","fields":"{\"status\":\"active\"}"}`))
|
||||
case http.MethodPatch:
|
||||
captured.ServeHTTP(w, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ref":"PLAN-5","status":"updated"}`))
|
||||
default:
|
||||
t.Fatalf("unexpected method %s", r.Method)
|
||||
}
|
||||
})
|
||||
|
||||
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
||||
ctx := WithDispatchInput(context.Background(), map[string]any{
|
||||
"workspace": "docapp",
|
||||
"ref": "PLAN-5",
|
||||
"status": "completed",
|
||||
"force": true,
|
||||
})
|
||||
res, err := d.Dispatch(ctx, []string{"item", "update"}, nil)
|
||||
if err != nil || (res != nil && res.IsError) {
|
||||
t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res)
|
||||
}
|
||||
if captured.requestCount != 1 {
|
||||
t.Fatalf("expected 1 PATCH, got %d", captured.requestCount)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal([]byte(captured.lastBody), &body); err != nil {
|
||||
t.Fatalf("decode body: %v\n%s", err, captured.lastBody)
|
||||
}
|
||||
got, ok := body["force"].(bool)
|
||||
if !ok || !got {
|
||||
t.Errorf("expected force=true in PATCH body, got %v (%T) — body=%s",
|
||||
body["force"], body["force"], captured.lastBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchItemUpdate_OmitsForceWhenAbsent(t *testing.T) {
|
||||
captured := newRequestCapture()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/workspaces/docapp/items/PLAN-5", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ref":"PLAN-5","fields":"{\"status\":\"active\"}"}`))
|
||||
case http.MethodPatch:
|
||||
captured.ServeHTTP(w, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ref":"PLAN-5","status":"updated"}`))
|
||||
}
|
||||
})
|
||||
|
||||
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
||||
ctx := WithDispatchInput(context.Background(), map[string]any{
|
||||
"workspace": "docapp",
|
||||
"ref": "PLAN-5",
|
||||
"status": "completed",
|
||||
})
|
||||
if _, err := d.Dispatch(ctx, []string{"item", "update"}, nil); err != nil {
|
||||
t.Fatalf("Dispatch err: %v", err)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal([]byte(captured.lastBody), &body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if _, present := body["force"]; present {
|
||||
t.Errorf("force should be omitted from PATCH body when not set, got body=%s", captured.lastBody)
|
||||
}
|
||||
}
|
||||
@@ -293,7 +293,14 @@ func (d *HTTPHandlerDispatcher) dispatchItemBulkUpdate(
|
||||
continue
|
||||
}
|
||||
fieldsStr := string(fieldsJSON)
|
||||
patchBody, err := json.Marshal(map[string]any{"fields": fieldsStr})
|
||||
patchPayload := map[string]any{"fields": fieldsStr}
|
||||
// IDEA-1494: forward the open-children guard override per-row.
|
||||
// Same flag shape as `pad item bulk-update --force` so the
|
||||
// override travels through both transports identically.
|
||||
if b, ok := input["force"].(bool); ok && b {
|
||||
patchPayload["force"] = true
|
||||
}
|
||||
patchBody, err := json.Marshal(patchPayload)
|
||||
if err != nil {
|
||||
results = append(results, bulkResult{Ref: ref, Error: rowDispatcherError("encode body", err)})
|
||||
continue
|
||||
|
||||
@@ -1011,6 +1011,12 @@ func mapItemMove(input map[string]any) (string, string, []byte, error) {
|
||||
}
|
||||
urlPath := fmt.Sprintf("/api/v1/workspaces/%s/items/%s/move",
|
||||
url.PathEscape(workspace), url.PathEscape(ref))
|
||||
// IDEA-1494 R3 P1: forward the open-children guard override into
|
||||
// the URL query, matching the server-side contract (handler reads
|
||||
// ?force=true). Same wire shape `pad item move --force` uses.
|
||||
if b, ok := input["force"].(bool); ok && b {
|
||||
urlPath += "?force=true"
|
||||
}
|
||||
return http.MethodPost, urlPath, body, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,19 @@ const (
|
||||
// detail for debugging without promising any structured shape.
|
||||
ErrServerError ErrorCode = "server_error"
|
||||
|
||||
// ErrOpenChildren fires when handleUpdateItem rejects a
|
||||
// non-terminal → terminal done-field transition because the
|
||||
// item still has non-terminal children (IDEA-1494). The upstream
|
||||
// 409 body carries a `details.open_children` array (refs +
|
||||
// titles + statuses + collection_slug) plus a
|
||||
// `details.hidden_blocker_count` for blockers the caller can't
|
||||
// see. We surface the upstream `code` and `details` verbatim
|
||||
// rather than collapsing to ErrConflict so MCP-driven agents
|
||||
// can self-recover (ship the listed children, then retry — or
|
||||
// pass `force: true` if the children should be intentionally
|
||||
// orphaned). The hint mentions the same escape hatch.
|
||||
ErrOpenChildren ErrorCode = "open_children"
|
||||
|
||||
// ErrRateLimited fires on HTTP 429 responses — either the
|
||||
// general API limiter (per-user, 600/min, burst 60) or the
|
||||
// MCP per-token limiter (60/min/token, burst 60 post-BUG-1430)
|
||||
@@ -170,6 +183,15 @@ type ErrorPayload struct {
|
||||
// agents see why the call was rejected.
|
||||
RequiredRole string `json:"required_role,omitempty"`
|
||||
CurrentRole string `json:"current_role,omitempty"`
|
||||
|
||||
// Details carries the upstream error body's `details` object
|
||||
// verbatim when the upstream sets one. Used by ErrOpenChildren
|
||||
// (IDEA-1494) so MCP-driven agents get the same machine-readable
|
||||
// payload — open_children list, hidden_blocker_count, done_field,
|
||||
// attempted_value — that the HTTP API surfaces. json.RawMessage
|
||||
// because the shape is code-specific; clients re-parse against
|
||||
// the known schema for their code branch.
|
||||
Details json.RawMessage `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// WorkspaceHint is a minimal workspace summary surfaced in the
|
||||
@@ -294,10 +316,114 @@ func envelopeFrom(res *mcp.CallToolResult) ErrorEnvelope {
|
||||
// with the raw stderr preserved in Message.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// structuredErrorMarker mirrors internal/cli.StructuredErrorMarker —
|
||||
// the versioned line prefix the CLI writes to stderr when it surfaces
|
||||
// a structured error. Duplicated (rather than imported) so the mcp
|
||||
// classifier doesn't pull the cli package's dependency graph in for
|
||||
// one string. Codex round-3 P3 introduced the versioned shape.
|
||||
//
|
||||
// IMPORTANT: keep in lockstep with cli.StructuredErrorMarker AND with
|
||||
// allowedStructuredErrorCodes below. A wire-shape change requires
|
||||
// bumping both packages and reviewing the allow-list.
|
||||
const structuredErrorMarker = "pad-structured-error/v1: "
|
||||
|
||||
// allowedStructuredErrorCodes is the whitelist of upstream codes the
|
||||
// MCP layer will surface verbatim. Two transports consult it:
|
||||
//
|
||||
// - Stdio: extractStructuredCLIError gates the `pad-structured-error/v1:`
|
||||
// marker on this set (Codex round-3 P3).
|
||||
// - HTTP: classifyHTTPStatus's 409 branch gates the upstream
|
||||
// `error.code` pass-through on this set (Codex round-4 P2).
|
||||
//
|
||||
// Routing BOTH transports through the same whitelist keeps the
|
||||
// ErrorCode enum's closed-contract honest — agents see the same set
|
||||
// of structured codes regardless of which dispatcher delivered the
|
||||
// response. Pre-round-4 the HTTP path forwarded any non-"conflict"
|
||||
// upstream code, which silently widened the enum and let the two
|
||||
// transports diverge.
|
||||
//
|
||||
// Adding a new structured code is a TWO-WAY change: the pad handler
|
||||
// must emit it (and emit `details` with a stable, agent-safe shape)
|
||||
// AND it must be added here. The matching ErrorCode constant in the
|
||||
// declarations block at the top of this file should also be added
|
||||
// for type safety in test assertions.
|
||||
var allowedStructuredErrorCodes = map[string]struct{}{
|
||||
"open_children": {}, // IDEA-1494
|
||||
}
|
||||
|
||||
// extractStructuredCLIError scans stderr for the
|
||||
// `pad-structured-error/v1: {json}\n` marker line and lifts the
|
||||
// structured error envelope into an MCP result. Returns nil when no
|
||||
// marker is found OR the payload fails any of the hardening checks
|
||||
// (caller falls back to the regex-based classifiers).
|
||||
//
|
||||
// Hardening (Codex round-3 P3):
|
||||
// - Versioned marker. Unknown versions return nil.
|
||||
// - Whitelisted codes. Unknown codes return nil.
|
||||
// - Last-marker-wins. If multiple markers appear in stderr (a
|
||||
// pathological case today, but possible if a future caller emits
|
||||
// several), we use the LAST one — that's the most recent
|
||||
// classification the CLI emitted, and a malicious earlier line
|
||||
// can't pre-empt it.
|
||||
// - Marker must start the line after trimming whitespace; markers
|
||||
// embedded mid-line (e.g. in a quoted log message) are ignored.
|
||||
func extractStructuredCLIError(stderr string) *mcp.CallToolResult {
|
||||
if !strings.Contains(stderr, structuredErrorMarker) {
|
||||
return nil
|
||||
}
|
||||
var lastPayload string
|
||||
for _, line := range strings.Split(stderr, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, structuredErrorMarker) {
|
||||
continue
|
||||
}
|
||||
lastPayload = strings.TrimPrefix(line, structuredErrorMarker)
|
||||
}
|
||||
if lastPayload == "" {
|
||||
return nil
|
||||
}
|
||||
var env struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details json.RawMessage `json:"details,omitempty"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(lastPayload), &env); err != nil {
|
||||
// Malformed marker payload — fall through to regex
|
||||
// classification rather than blowing up the agent.
|
||||
return nil
|
||||
}
|
||||
if env.Error.Code == "" {
|
||||
return nil
|
||||
}
|
||||
if _, ok := allowedStructuredErrorCodes[env.Error.Code]; !ok {
|
||||
// Unknown code. Don't forward — regex classifier handles it.
|
||||
return nil
|
||||
}
|
||||
return NewErrorResult(ErrorPayload{
|
||||
Code: ErrorCode(env.Error.Code),
|
||||
Message: env.Error.Message,
|
||||
Details: env.Error.Details,
|
||||
})
|
||||
}
|
||||
|
||||
// classifyExecError turns an exec.Cmd failure (err + stderr) into a
|
||||
// structured envelope. lookup is optional — when supplied, no_workspace
|
||||
// errors get available_workspaces enrichment.
|
||||
func classifyExecError(ctx context.Context, cmdPath []string, runErr error, stderr string, lookup WorkspaceLister) *mcp.CallToolResult {
|
||||
// IDEA-1494 R2: the CLI emits a single `pad-error: {json}` line
|
||||
// on stderr when surfacing the open-children rejection (see
|
||||
// internal/cli/client.go::WriteOpenChildrenError). Detect it
|
||||
// before regex classification so we lift the structured `code`
|
||||
// + `details` straight through to the MCP envelope instead of
|
||||
// pattern-matching free-form text and downgrading to
|
||||
// validation_failed. Matched on a literal prefix to keep the
|
||||
// classifier cheap and the contract obvious.
|
||||
if structured := extractStructuredCLIError(stderr); structured != nil {
|
||||
return structured
|
||||
}
|
||||
|
||||
// BUG-987 bug 11: cobra automatically appends a "Usage: ..." block
|
||||
// to stderr when a command fails with a runtime error. That help
|
||||
// text uses the OLD CLI verb names (e.g. `pad item block`) which
|
||||
@@ -634,6 +760,29 @@ func classifyHTTPStatusKind(
|
||||
case http.StatusNotFound:
|
||||
return classify404(ctx, cmdKey, route, bodyText, bodyMessage, lookup, kind, refOrSlug)
|
||||
case http.StatusConflict:
|
||||
// IDEA-1494 R2/R4 P2: preserve the upstream `code` + `details`
|
||||
// when the handler emitted a structured rejection — but ONLY
|
||||
// for codes that appear in the shared allow-list
|
||||
// (allowedStructuredErrorCodes, defined below). The stdio
|
||||
// classifier already gates on the same set; routing both
|
||||
// transports through the same whitelist keeps the
|
||||
// ErrorCode enum's closed contract honest (round-4 P2:
|
||||
// HTTP was forwarding any non-"conflict" code, diverging
|
||||
// from stdio).
|
||||
//
|
||||
// New structured codes get added to allowedStructuredErrorCodes
|
||||
// in one place; both transports adopt them in lockstep.
|
||||
// Unknown codes fall through to generic ErrConflict here —
|
||||
// matching what stdio does for an unknown-code marker.
|
||||
upstream := extractUpstreamErrorEnvelope(bodyText)
|
||||
if _, allowed := allowedStructuredErrorCodes[upstream.Code]; allowed {
|
||||
return NewErrorResult(ErrorPayload{
|
||||
Code: ErrorCode(upstream.Code),
|
||||
Message: upstream.Message,
|
||||
Hint: conflictHintFor(bodyMessage, route),
|
||||
Details: upstream.Details,
|
||||
})
|
||||
}
|
||||
return NewErrorResult(ErrorPayload{
|
||||
Code: ErrConflict,
|
||||
Message: "Conflict — current state changed beneath this update.",
|
||||
@@ -787,6 +936,37 @@ func extractUpstreamMessage(body string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// upstreamErrorEnvelope is the broader extractor used when the
|
||||
// dispatcher needs to pass through the upstream `code` / `details`
|
||||
// fields (not just the message). The pad backend uses the same
|
||||
// `{"error":{"code":..., "message":..., "details":{...}}}` shape
|
||||
// across every handler that returns writeError-style bodies; this
|
||||
// parser lifts those fields when present and reports zero-valued
|
||||
// strings / nil RawMessage when not. Single source of truth for the
|
||||
// pass-through cases in classifyHTTPStatusKind.
|
||||
type upstreamErrorEnvelope struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details json.RawMessage `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
func extractUpstreamErrorEnvelope(body string) upstreamErrorEnvelope {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return upstreamErrorEnvelope{}
|
||||
}
|
||||
if strings.EqualFold(body, "404 page not found") {
|
||||
return upstreamErrorEnvelope{}
|
||||
}
|
||||
var env struct {
|
||||
Error upstreamErrorEnvelope `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &env); err == nil {
|
||||
return env.Error
|
||||
}
|
||||
return upstreamErrorEnvelope{}
|
||||
}
|
||||
|
||||
// itemMissingHint is the per-error-code hint generator for
|
||||
// ErrItemNotFound. References the actual ref + route so the agent
|
||||
// can pin the failure without re-parsing the message, plus points
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package mcp
|
||||
|
||||
// IDEA-1494 R2 — MCP-level coverage for the open-children code/details
|
||||
// pass-through, both HTTP and stdio.
|
||||
//
|
||||
// The contract:
|
||||
//
|
||||
// - HTTP path: when the upstream PATCH returns 409 with code=
|
||||
// "open_children" and a populated details body, the dispatcher
|
||||
// surfaces ErrOpenChildren (not the generic ErrConflict) and
|
||||
// preserves the details RawMessage verbatim. Agents branch on
|
||||
// the code, then re-parse details against the known shape.
|
||||
// - stdio path: when the CLI's stderr carries a `pad-error: {json}`
|
||||
// marker line, classifyExecError detects it, lifts the structured
|
||||
// payload, and surfaces it in the same shape — independent of
|
||||
// transport.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/cli"
|
||||
)
|
||||
|
||||
func TestClassifyHTTPStatus_OpenChildrenPreservesCodeAndDetails(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"error": {
|
||||
"code": "open_children",
|
||||
"message": "cannot mark PLAN-5 completed: 2 open children still in a non-terminal state. Pass --force to override.",
|
||||
"details": {
|
||||
"open_children": [
|
||||
{"ref":"TASK-7","title":"a","status":"open","collection_slug":"tasks"},
|
||||
{"ref":"TASK-8","title":"b","status":"open","collection_slug":"tasks"}
|
||||
],
|
||||
"hidden_blocker_count": 0,
|
||||
"done_field": "status",
|
||||
"attempted_value": "completed"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
res := classifyHTTPStatus(context.Background(), "item update", 409, body, nil)
|
||||
env, ok := res.StructuredContent.(ErrorEnvelope)
|
||||
if !ok {
|
||||
t.Fatalf("expected ErrorEnvelope, got %T", res.StructuredContent)
|
||||
}
|
||||
if env.Error.Code != ErrOpenChildren {
|
||||
t.Fatalf("code: got %q, want %q", env.Error.Code, ErrOpenChildren)
|
||||
}
|
||||
if env.Error.Message == "" {
|
||||
t.Errorf("message should be preserved from upstream, got empty")
|
||||
}
|
||||
if len(env.Error.Details) == 0 {
|
||||
t.Fatal("details should be preserved as raw JSON, got empty")
|
||||
}
|
||||
|
||||
// Round-trip the details into the shared CLI struct (the same one
|
||||
// MCP-driven agents would unmarshal against). Confirms the shape
|
||||
// survives the HTTP-classifier transit unchanged.
|
||||
var got cli.OpenChildrenDetails
|
||||
if err := json.Unmarshal(env.Error.Details, &got); err != nil {
|
||||
t.Fatalf("details should round-trip into OpenChildrenDetails: %v", err)
|
||||
}
|
||||
if len(got.OpenChildren) != 2 {
|
||||
t.Errorf("open_children len: got %d, want 2", len(got.OpenChildren))
|
||||
}
|
||||
if got.AttemptedValue != "completed" {
|
||||
t.Errorf("attempted_value: got %q, want completed", got.AttemptedValue)
|
||||
}
|
||||
if got.DoneField != "status" {
|
||||
t.Errorf("done_field: got %q, want status", got.DoneField)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyHTTPStatus_ConflictWithoutCodeFallsToErrConflict pins
|
||||
// the inverse: a 409 WITHOUT an upstream code (or with code="conflict"
|
||||
// explicitly) still collapses to ErrConflict so we don't break the
|
||||
// existing classifier contract for ordinary version-mismatch 409s.
|
||||
func TestClassifyHTTPStatus_ConflictWithoutCodeFallsToErrConflict(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"no code at all", `{"error":{"message":"version mismatch"}}`},
|
||||
{"explicit conflict code", `{"error":{"code":"conflict","message":"version mismatch"}}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res := classifyHTTPStatus(context.Background(), "item update", 409, []byte(tc.body), nil)
|
||||
env := res.StructuredContent.(ErrorEnvelope)
|
||||
if env.Error.Code != ErrConflict {
|
||||
t.Errorf("code: got %q, want %q", env.Error.Code, ErrConflict)
|
||||
}
|
||||
if len(env.Error.Details) != 0 {
|
||||
t.Errorf("details should be empty for generic 409, got %s", string(env.Error.Details))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyExecError_OpenChildrenMarkerLiftsStructuredPayload(t *testing.T) {
|
||||
stderr := `Error: connecting to backend
|
||||
pad-structured-error/v1: {"error":{"code":"open_children","message":"cannot mark PLAN-5 completed: 1 open child still in a non-terminal state. Pass --force to override.","details":{"open_children":[{"ref":"TASK-7","title":"x","status":"open","collection_slug":"tasks"}],"hidden_blocker_count":0,"done_field":"status","attempted_value":"completed"}}}
|
||||
cannot mark PLAN-5 completed: 1 open child still in a non-terminal state. Pass --force to override.
|
||||
TASK-7 — x (status=open)
|
||||
Pass --force to override.
|
||||
`
|
||||
res := classifyExecError(context.Background(),
|
||||
[]string{"item", "update"},
|
||||
errors.New("exit status 1"),
|
||||
stderr,
|
||||
nil)
|
||||
env, ok := res.StructuredContent.(ErrorEnvelope)
|
||||
if !ok {
|
||||
t.Fatalf("expected ErrorEnvelope, got %T", res.StructuredContent)
|
||||
}
|
||||
if env.Error.Code != ErrOpenChildren {
|
||||
t.Fatalf("code: got %q, want %q", env.Error.Code, ErrOpenChildren)
|
||||
}
|
||||
var details cli.OpenChildrenDetails
|
||||
if err := json.Unmarshal(env.Error.Details, &details); err != nil {
|
||||
t.Fatalf("details did not round-trip: %v", err)
|
||||
}
|
||||
if len(details.OpenChildren) != 1 || details.OpenChildren[0].Ref != "TASK-7" {
|
||||
t.Errorf("open_children mismatch: %+v", details.OpenChildren)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyExecError_NoMarkerFallsThrough confirms stderr WITHOUT
|
||||
// the marker classifies via the existing regex matchers (validation /
|
||||
// auth / item-not-found / generic) — i.e. the marker detection is
|
||||
// purely additive and doesn't break the pre-IDEA-1494 stderr-classify
|
||||
// contracts.
|
||||
func TestClassifyExecError_NoMarkerFallsThrough(t *testing.T) {
|
||||
stderr := "Error: invalid status value\n"
|
||||
res := classifyExecError(context.Background(),
|
||||
[]string{"item", "update"},
|
||||
errors.New("exit status 1"),
|
||||
stderr,
|
||||
nil)
|
||||
env := res.StructuredContent.(ErrorEnvelope)
|
||||
if env.Error.Code == ErrOpenChildren {
|
||||
t.Errorf("plain validation stderr must not be classified as open_children")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyExecError_UnknownStructuredCodeFallsThrough covers
|
||||
// Codex round-3 P3 marker hardening: even with a well-formed
|
||||
// pad-structured-error/v1 marker, a code that's NOT in the allow-list
|
||||
// must not be surfaced — fall back to regex classification instead.
|
||||
// Prevents a CLI bug or third-party tool from smuggling an
|
||||
// unsanitized code past the MCP boundary.
|
||||
func TestClassifyExecError_UnknownStructuredCodeFallsThrough(t *testing.T) {
|
||||
stderr := `pad-structured-error/v1: {"error":{"code":"made_up_code","message":"x"}}
|
||||
Error: invalid status value
|
||||
`
|
||||
res := classifyExecError(context.Background(),
|
||||
[]string{"item", "update"},
|
||||
errors.New("exit status 1"),
|
||||
stderr,
|
||||
nil)
|
||||
env := res.StructuredContent.(ErrorEnvelope)
|
||||
if env.Error.Code == ErrorCode("made_up_code") {
|
||||
t.Errorf("unknown structured code must not be surfaced; got %q", env.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyExecError_OldMarkerVersionIgnored confirms that an
|
||||
// older (or unrecognized future) marker version is ignored rather
|
||||
// than parsed. Validates the "version token is parsed, not just
|
||||
// prefix-matched" property — agents on the new mcp build won't be
|
||||
// confused by stderr from a stale CLI binary that still uses the
|
||||
// pre-round-3 unversioned `pad-error:` shape.
|
||||
func TestClassifyExecError_OldMarkerVersionIgnored(t *testing.T) {
|
||||
stderr := `pad-error: {"error":{"code":"open_children","message":"x"}}
|
||||
Error: invalid status value
|
||||
`
|
||||
res := classifyExecError(context.Background(),
|
||||
[]string{"item", "update"},
|
||||
errors.New("exit status 1"),
|
||||
stderr,
|
||||
nil)
|
||||
env := res.StructuredContent.(ErrorEnvelope)
|
||||
if env.Error.Code == ErrOpenChildren {
|
||||
t.Errorf("pre-v1 unversioned marker must not be lifted; got %q", env.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyExecError_MarkerEmbeddedMidLineIgnored ensures a marker
|
||||
// substring inside a quoted log message can't impersonate a real
|
||||
// structured error. The classifier requires the marker at the line's
|
||||
// start (after whitespace trim) — embedded variants fall through.
|
||||
func TestClassifyExecError_MarkerEmbeddedMidLineIgnored(t *testing.T) {
|
||||
stderr := `Error: backend logged "pad-structured-error/v1: {\"error\":{\"code\":\"open_children\"}}"
|
||||
`
|
||||
res := classifyExecError(context.Background(),
|
||||
[]string{"item", "update"},
|
||||
errors.New("exit status 1"),
|
||||
stderr,
|
||||
nil)
|
||||
env := res.StructuredContent.(ErrorEnvelope)
|
||||
if env.Error.Code == ErrOpenChildren {
|
||||
t.Errorf("embedded marker must not be lifted; got %q", env.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyExecError_LastMarkerWins confirms multiple markers
|
||||
// resolve to the LAST one — a malicious earlier line can't pre-empt
|
||||
// the CLI's actual final classification.
|
||||
func TestClassifyExecError_LastMarkerWins(t *testing.T) {
|
||||
stderr := `pad-structured-error/v1: {"error":{"code":"made_up_code","message":"first"}}
|
||||
pad-structured-error/v1: {"error":{"code":"open_children","message":"second","details":{"open_children":[]}}}
|
||||
`
|
||||
res := classifyExecError(context.Background(),
|
||||
[]string{"item", "update"},
|
||||
errors.New("exit status 1"),
|
||||
stderr,
|
||||
nil)
|
||||
env := res.StructuredContent.(ErrorEnvelope)
|
||||
if env.Error.Code != ErrOpenChildren {
|
||||
t.Errorf("expected the later marker to win; got %q (message=%q)",
|
||||
env.Error.Code, env.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyHTTPStatus_UnknownConflictCodeFallsBackToErrConflict
|
||||
// covers Codex round-4 P2: HTTP and stdio must agree on the closed
|
||||
// set of structured codes they surface. Pre-fix the HTTP path
|
||||
// forwarded ANY non-"conflict" code; stdio whitelisted only
|
||||
// open_children. The two transports diverged on what an agent saw
|
||||
// from an upstream that emitted `code=some_future_code`.
|
||||
//
|
||||
// Post-fix: both consult allowedStructuredErrorCodes. An upstream
|
||||
// 409 with a code NOT in the whitelist collapses to generic
|
||||
// ErrConflict on the HTTP path, mirroring what the stdio path does
|
||||
// for an unknown-code structured marker.
|
||||
func TestClassifyHTTPStatus_UnknownConflictCodeFallsBackToErrConflict(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"error": {
|
||||
"code": "some_future_code",
|
||||
"message": "x",
|
||||
"details": {"anything":1}
|
||||
}
|
||||
}`)
|
||||
res := classifyHTTPStatus(context.Background(), "item update", 409, body, nil)
|
||||
env := res.StructuredContent.(ErrorEnvelope)
|
||||
if env.Error.Code != ErrConflict {
|
||||
t.Errorf("unknown upstream code must collapse to ErrConflict on HTTP path; got %q", env.Error.Code)
|
||||
}
|
||||
if len(env.Error.Details) != 0 {
|
||||
t.Errorf("details from un-whitelisted code must not leak; got %s", string(env.Error.Details))
|
||||
}
|
||||
}
|
||||
|
||||
// TestStructuredErrorCodeParityAcrossTransports asserts that the same
|
||||
// "unknown code" rejection produces the same envelope shape from
|
||||
// both transports — ie HTTP doesn't widen the enum behind stdio's
|
||||
// back. Round-4 P2.
|
||||
func TestStructuredErrorCodeParityAcrossTransports(t *testing.T) {
|
||||
httpRes := classifyHTTPStatus(context.Background(), "item update", 409,
|
||||
[]byte(`{"error":{"code":"made_up_code","message":"x"}}`), nil)
|
||||
httpEnv := httpRes.StructuredContent.(ErrorEnvelope)
|
||||
|
||||
stdioRes := classifyExecError(context.Background(),
|
||||
[]string{"item", "update"},
|
||||
errors.New("exit status 1"),
|
||||
`pad-structured-error/v1: {"error":{"code":"made_up_code","message":"x"}}
|
||||
`, nil)
|
||||
stdioEnv := stdioRes.StructuredContent.(ErrorEnvelope)
|
||||
|
||||
if httpEnv.Error.Code == ErrorCode("made_up_code") {
|
||||
t.Errorf("HTTP path leaked unknown code: %q", httpEnv.Error.Code)
|
||||
}
|
||||
if stdioEnv.Error.Code == ErrorCode("made_up_code") {
|
||||
t.Errorf("stdio path leaked unknown code: %q", stdioEnv.Error.Code)
|
||||
}
|
||||
// Both transports must agree: neither surfaces the unknown code.
|
||||
// They CAN classify the fallback differently (HTTP → ErrConflict
|
||||
// for 409, stdio → ErrServerError for an unknown stderr line)
|
||||
// because the upstream signals are genuinely different — the
|
||||
// parity contract is "no transport widens the enum beyond the
|
||||
// whitelist," not "both transports produce identical envelopes."
|
||||
}
|
||||
@@ -582,6 +582,15 @@ type ItemUpdate struct {
|
||||
// (since nil pointer means "don't change" in partial updates)
|
||||
ClearAssignedUser bool `json:"clear_assigned_user,omitempty"`
|
||||
ClearAgentRole bool `json:"clear_agent_role,omitempty"`
|
||||
|
||||
// Force, when true, overrides the server-side open-children guard
|
||||
// (IDEA-1494) that otherwise rejects a non-terminal → terminal
|
||||
// done-field transition while the item still has non-terminal
|
||||
// children. Transport-only: the store layer never sees it (the
|
||||
// handler consumes it before calling UpdateItem). Used by `pad item
|
||||
// update --force` and MCP `pad_item.action: update` with
|
||||
// `force: true`.
|
||||
Force bool `json:"force,omitempty"`
|
||||
}
|
||||
|
||||
// ErrInvalidFieldsType / ErrInvalidTagsType are returned by
|
||||
|
||||
@@ -664,6 +664,19 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
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: parent-link change is captured here and
|
||||
// applied AFTER the main UpdateItemWithPreCheck succeeds, so a
|
||||
// guard rejection on the status field doesn't leave a committed
|
||||
// link change behind. Hoisted out of the `if input.Fields != nil`
|
||||
// block so the post-write step 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)
|
||||
@@ -687,8 +700,9 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
// 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
|
||||
var parentProvided bool
|
||||
// (parentValue / parentProvided are declared at outer scope so
|
||||
// the deferred link-write block below can read them — see
|
||||
// IDEA-1494 R4 P3.)
|
||||
for _, key := range []string{"parent", "plan"} {
|
||||
if schemaHasField(schema, key) {
|
||||
continue
|
||||
@@ -740,6 +754,71 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
validatedFields, err := json.Marshal(fieldMap)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to marshal validated fields")
|
||||
@@ -748,22 +827,25 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
validated := string(validatedFields)
|
||||
input.Fields = &validated
|
||||
|
||||
// Update parent link if parent was provided in the update
|
||||
if parentProvided {
|
||||
if parentValue != "" {
|
||||
actor, _ := actorFromRequest(r)
|
||||
if _, err := s.store.SetParentLink(workspaceID, item.ID, parentValue, actor); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", fmt.Sprintf("failed to update parent link: %v", err))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Parent was explicitly set to empty/null — clear the link
|
||||
if err := s.store.ClearParentLink(item.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", fmt.Sprintf("failed to clear parent link: %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// IDEA-1494 R4 P3: parent-link mutations are DEFERRED until
|
||||
// after the field write succeeds. Pre-fix this block ran the
|
||||
// link update INLINE here — before the precheck even fired —
|
||||
// so a PATCH that combined a parent change with a status flip
|
||||
// could end up with the link committed AND the field write
|
||||
// rejected by the guard. Caller saw 409 but the parent had
|
||||
// already moved.
|
||||
//
|
||||
// Now we record the desired link change and execute it ONLY
|
||||
// when the main UpdateItemWithPreCheck call below succeeds.
|
||||
// A guard rejection short-circuits with the link untouched.
|
||||
// Documented choice: "reorder, don't tx-wrap" — wrapping the
|
||||
// link mutation in the same store tx as UpdateItem would
|
||||
// require threading a tx through SetParentLink (which has
|
||||
// its own tx already, and is also called from the
|
||||
// handler_item_links path). Reordering is the smaller surgery
|
||||
// and preserves atomicity in the failure direction — caller
|
||||
// only sees both writes on success, or neither (with a clear
|
||||
// 409) on rejection.
|
||||
}
|
||||
|
||||
// Designated-applier routing (PLAN-1248 TASK-1257). When the
|
||||
@@ -880,7 +962,7 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
return errStaleCollabSnapshot
|
||||
}
|
||||
}
|
||||
updated, uerr := s.store.UpdateItem(item.ID, input)
|
||||
updated, uerr := s.store.UpdateItemWithPreCheck(item.ID, input, openChildrenPrecheck)
|
||||
if uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
@@ -889,6 +971,10 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if details, ok := asOpenChildrenGuardError(err); ok {
|
||||
writeOpenChildrenError(w, itemRefOrSlug(*item), details)
|
||||
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.")
|
||||
@@ -944,7 +1030,7 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
// Store.UpdateItem's content-versioning peek at Title.
|
||||
// Per Codex review round 9.
|
||||
err := s.applyContentViaCollab(r, item.ID, *input.Content, func() error {
|
||||
updated, uerr := s.store.UpdateItem(item.ID, input)
|
||||
updated, uerr := s.store.UpdateItemWithPreCheck(item.ID, input, openChildrenPrecheck)
|
||||
if uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
@@ -958,8 +1044,15 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
// or directWrite ran the full UpdateItem inside the
|
||||
// lock (fullWriteHandled tracks which).
|
||||
input.Content = nil
|
||||
} else if details, ok := asOpenChildrenGuardError(err); ok {
|
||||
// IDEA-1494 R2: the open-children guard fired inside the
|
||||
// directWrite callback. Don't let applyContentViaCollab's
|
||||
// "any error falls through" policy retry the write —
|
||||
// rejection is final.
|
||||
writeOpenChildrenError(w, itemRefOrSlug(*item), details)
|
||||
return
|
||||
}
|
||||
// Any error path (e.g. ErrAllAppliersTimedOut, retry
|
||||
// Any other error path (e.g. ErrAllAppliersTimedOut, retry
|
||||
// exhaustion) falls through to direct write — graceful
|
||||
// degradation. The helper logs the specifics so operators
|
||||
// see degraded paths.
|
||||
@@ -969,9 +1062,18 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
if fullWriteHandled {
|
||||
updated = fullWriteUpdated
|
||||
} else {
|
||||
updated, err = s.store.UpdateItem(item.ID, input)
|
||||
updated, err = s.store.UpdateItemWithPreCheck(item.ID, input, openChildrenPrecheck)
|
||||
}
|
||||
if err != nil {
|
||||
// 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
|
||||
}
|
||||
// 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
|
||||
@@ -988,6 +1090,34 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// IDEA-1494 R4 P3: deferred parent-link mutation. Runs ONLY now
|
||||
// that the main UpdateItemWithPreCheck succeeded (the precheck
|
||||
// passed AND the field write committed). A guard rejection above
|
||||
// `return`s before reaching here, so the link is untouched on
|
||||
// the failure path.
|
||||
//
|
||||
// Failure of the link write itself after a successful field
|
||||
// write is still possible (e.g. SetParentLink discovers a cycle
|
||||
// the cycle-check missed, or a DB error). We surface that as a
|
||||
// 500 with the field write already committed — same partial-
|
||||
// success window the pre-IDEA-1494 code had in the OTHER
|
||||
// direction, and not made worse by the reorder. A future tx-
|
||||
// wrap fix could close this entirely; out of scope for round 4.
|
||||
if parentProvided {
|
||||
if parentValue != "" {
|
||||
actor, _ := actorFromRequest(r)
|
||||
if _, err := s.store.SetParentLink(workspaceID, item.ID, parentValue, actor); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", fmt.Sprintf("failed to update parent link: %v", err))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := s.store.ClearParentLink(item.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", fmt.Sprintf("failed to clear parent link: %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build rich metadata describing what changed
|
||||
var meta string
|
||||
if changes := diffFields(item.Fields, updated.Fields); changes != "" {
|
||||
@@ -1259,9 +1389,72 @@ func (s *Server) handleMoveItem(w http.ResponseWriter, r *http.Request) {
|
||||
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 movePrecheck 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,
|
||||
}
|
||||
movePrecheck = 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
|
||||
}
|
||||
}
|
||||
|
||||
// Move the item
|
||||
moved, err := s.store.MoveItem(item.ID, targetColl.ID, string(fieldsJSON))
|
||||
moved, err := s.store.MoveItemWithPreCheck(item.ID, targetColl.ID, string(fieldsJSON), movePrecheck)
|
||||
if err != nil {
|
||||
if details, ok := asOpenChildrenGuardError(err); ok {
|
||||
writeOpenChildrenError(w, itemRefOrSlug(*item), details)
|
||||
return
|
||||
}
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
package server
|
||||
|
||||
// IDEA-1494 — Refuse to mark an item terminal while it still has
|
||||
// non-terminal children. The guard fires server-side so it covers
|
||||
// every interface (CLI, MCP, web UI) that hits handleUpdateItem.
|
||||
//
|
||||
// Trigger conditions (all must hold):
|
||||
// 1. The PATCH supplies a fields update.
|
||||
// 2. The new value for the parent collection's resolved done-field
|
||||
// key is in TerminalValuesForDoneField.
|
||||
// 3. The CURRENT value for that key is NOT in the terminal set.
|
||||
// (no-op terminal → terminal and terminal-to-terminal transitions
|
||||
// bypass the guard — only entering the terminal set is gated.)
|
||||
// 4. The parent item has at least one non-deleted child whose own
|
||||
// collection schema reports the child as non-terminal.
|
||||
//
|
||||
// The caller can override the guard with `--force` (CLI) / `force: true`
|
||||
// (MCP body field). The override still records the status change.
|
||||
//
|
||||
// ── Visibility (Codex round 2 P1) ─────────────────────────────────────
|
||||
// The invariant itself is a DATA-INTEGRITY gate, not a visibility
|
||||
// gate: we evaluate it against ALL children (so a caller with reduced
|
||||
// visibility can't close a parent that has children they don't see).
|
||||
// The 409 response payload, however, is sanitized — only children the
|
||||
// caller is allowed to see appear in `details.open_children`. When
|
||||
// hidden children contributed to the rejection (in part or in full)
|
||||
// the payload carries a separate `hidden_blocker_count` so MCP-driven
|
||||
// agents can distinguish:
|
||||
//
|
||||
// - len(open_children)==0 + hidden_blocker_count==0 → would not have
|
||||
// rejected (no path here, but the shape is unambiguous);
|
||||
// - len(open_children)==N + hidden_blocker_count==0 → caller can see
|
||||
// every blocker;
|
||||
// - len(open_children)==N + hidden_blocker_count==M → caller sees N
|
||||
// blockers + M additional that they can't access; recovery requires
|
||||
// either coordination with someone who can or `--force` if their
|
||||
// role permits.
|
||||
//
|
||||
// ── Atomicity (Codex round 2 P2) ──────────────────────────────────────
|
||||
// The guard query runs INSIDE the same store transaction as the
|
||||
// UPDATE, so a concurrent child insert / child status flip can't slip
|
||||
// between the read and the write. See Store.UpdateItemWithPreCheck +
|
||||
// Store.AcquireParentChildrenLocks for the locking shape.
|
||||
//
|
||||
// ── Response (HTTP 409 Conflict) ──────────────────────────────────────
|
||||
//
|
||||
// {
|
||||
// "error": {
|
||||
// "code": "open_children",
|
||||
// "message": "cannot mark TASK-5 completed: ...",
|
||||
// "details": {
|
||||
// "open_children": [
|
||||
// {"ref":"TASK-7","title":"...","status":"open","collection_slug":"tasks"},
|
||||
// ...
|
||||
// ],
|
||||
// "hidden_blocker_count": 0,
|
||||
// "done_field": "status",
|
||||
// "attempted_value": "completed"
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// `details.open_children` is the canonical machine-readable list — the
|
||||
// CLI renders the human message FROM the same list (plus the hidden
|
||||
// count) so the two paths agree by construction.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
|
||||
// errOpenChildrenGuard is the sentinel the precheck returns to the
|
||||
// store layer when the guard fires. The handler unwraps it via
|
||||
// errors.As to lift the structured details back out for the 409
|
||||
// response. Using a typed sentinel (rather than a generic error)
|
||||
// keeps the store/handler boundary clean — the store just sees "the
|
||||
// precheck rejected" and rolls back the tx.
|
||||
type openChildrenGuardError struct {
|
||||
details *openChildrenDetails
|
||||
}
|
||||
|
||||
func (e *openChildrenGuardError) Error() string {
|
||||
return fmt.Sprintf("open-children guard: %d visible + %d hidden blocker(s)",
|
||||
len(e.details.OpenChildren), e.details.HiddenBlockerCount)
|
||||
}
|
||||
|
||||
// itemRefOrSlug formats an item's issue ref (e.g. "TASK-5") when its
|
||||
// collection prefix + item number are populated, falling back to its
|
||||
// slug. Avoids pulling internal/cli into the server package for one
|
||||
// helper.
|
||||
func itemRefOrSlug(it models.Item) string {
|
||||
if it.CollectionPrefix != "" && it.ItemNumber != nil {
|
||||
return fmt.Sprintf("%s-%d", it.CollectionPrefix, *it.ItemNumber)
|
||||
}
|
||||
return it.Slug
|
||||
}
|
||||
|
||||
// openChildEntry is the per-child payload returned in the structured
|
||||
// error. Mirrors what `pad item list --parent X --status non-terminal`
|
||||
// would surface so MCP-driven agents can self-recover (e.g. ship the
|
||||
// children, then retry).
|
||||
type openChildEntry struct {
|
||||
Ref string `json:"ref"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
CollectionSlug string `json:"collection_slug"`
|
||||
}
|
||||
|
||||
// openChildrenDetails is the structured details payload returned in
|
||||
// the 409 error body. `OpenChildren` is filtered for caller visibility;
|
||||
// `HiddenBlockerCount` reports the number of additional blockers that
|
||||
// would also need to clear (or be force-overridden) but that the
|
||||
// caller can't see.
|
||||
type openChildrenDetails struct {
|
||||
OpenChildren []openChildEntry `json:"open_children"`
|
||||
HiddenBlockerCount int `json:"hidden_blocker_count"`
|
||||
DoneField string `json:"done_field"`
|
||||
AttemptedValue string `json:"attempted_value"`
|
||||
}
|
||||
|
||||
// openChildrenGuardContext bundles the read-only inputs the precheck
|
||||
// closure needs from the handler. Kept as a struct so the closure
|
||||
// signature stays narrow.
|
||||
type openChildrenGuardContext struct {
|
||||
r *http.Request
|
||||
workspaceID string
|
||||
itemID string
|
||||
parentSchema models.CollectionSchema
|
||||
parentSettings models.CollectionSettings
|
||||
newFieldMap map[string]any
|
||||
currentFieldsJS string
|
||||
// visibility filters, all pre-computed once by the handler.
|
||||
visibleCollectionIDs []string // nil = unrestricted
|
||||
guestFullCollIDs []string
|
||||
guestGrantedItemIDs []string
|
||||
}
|
||||
|
||||
// runOpenChildrenGuard executes the guard logic inside the
|
||||
// store-layer transaction. Returns:
|
||||
//
|
||||
// - (nil, nil) when the guard doesn't apply (no transition, no
|
||||
// children, or all children terminal) — the update should proceed.
|
||||
// - (details, nil) when the guard fires — the caller wraps these in
|
||||
// openChildrenGuardError so the store rolls back. `details` is
|
||||
// already sanitized for visibility.
|
||||
// - (nil, err) on infrastructure errors (DB read failed, etc.).
|
||||
func (s *Server) runOpenChildrenGuard(tx *sql.Tx, ctx openChildrenGuardContext) (*openChildrenDetails, error) {
|
||||
doneKey, terminalValues := models.TerminalValuesForDoneField(ctx.parentSchema, ctx.parentSettings)
|
||||
|
||||
// Trigger condition #1: the patch must set the resolved done-field
|
||||
// key to a terminal value.
|
||||
rawNew, ok := ctx.newFieldMap[doneKey]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
newStr, ok := rawNew.(string)
|
||||
if !ok || newStr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !valueInSet(newStr, terminalValues) {
|
||||
return nil, nil
|
||||
}
|
||||
// Trigger condition #3: the current value must NOT already be
|
||||
// terminal. terminal → terminal and no-op terminal transitions
|
||||
// bypass.
|
||||
currentVal := extractFieldString(ctx.currentFieldsJS, doneKey)
|
||||
if currentVal != "" && valueInSet(currentVal, terminalValues) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
children, err := s.store.GetChildItemsTx(tx, ctx.itemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load children for open-children guard: %w", err)
|
||||
}
|
||||
if len(children) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Per-child done evaluation against the child's OWN collection.
|
||||
// Cache schema+settings per child collection. The collection rows
|
||||
// don't need to be read from the same tx — schemas don't mutate
|
||||
// in the kind of races this guard cares about.
|
||||
ctxCache := make(map[string]doneContext)
|
||||
// Codex round-5 P3: initialize as empty (not nil) so the
|
||||
// hidden-only rejection path serializes `open_children: []`
|
||||
// rather than `null`. The contract documents this field as an
|
||||
// array; clients (CLI renderer, MCP agents) `range` over it
|
||||
// even when hidden_blocker_count > 0.
|
||||
open := []openChildEntry{}
|
||||
hidden := 0
|
||||
for i := range children {
|
||||
child := &children[i]
|
||||
dc, cached := ctxCache[child.CollectionID]
|
||||
if !cached {
|
||||
// Codex round-3 P3: include soft-deleted collections so a
|
||||
// child still attached to a soft-deleted collection is
|
||||
// evaluated against its own done-field schema, not the
|
||||
// default-status fallback (which would false-block when
|
||||
// the collection's done-field is e.g. `resolution` with
|
||||
// custom terminal_options). Mirrors the inclusion rule
|
||||
// childrenDoneFiltersForParent uses (items.go ≈2165).
|
||||
if coll, cerr := s.store.GetCollectionAnyState(child.CollectionID); cerr == nil && coll != nil {
|
||||
_ = json.Unmarshal([]byte(coll.Schema), &dc.schema)
|
||||
if coll.Settings != "" {
|
||||
_ = json.Unmarshal([]byte(coll.Settings), &dc.settings)
|
||||
}
|
||||
}
|
||||
ctxCache[child.CollectionID] = dc
|
||||
}
|
||||
// INVARIANT check uses ALL children. A restricted caller still
|
||||
// gets blocked by a non-terminal child they can't see — the
|
||||
// guard's purpose is data integrity, not visibility filtering.
|
||||
if isItemDone(child.Fields, child.CollectionID, map[string]doneContext{child.CollectionID: dc}) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Sanitize the response payload: surface only children this
|
||||
// caller has permission to see. Hidden blockers are counted
|
||||
// separately so the agent knows blocking state exists without
|
||||
// learning ref/title/status of items they can't access.
|
||||
if !s.openChildrenGuardChildVisible(ctx, child) {
|
||||
hidden++
|
||||
continue
|
||||
}
|
||||
childDoneKey, _ := models.TerminalValuesForDoneField(dc.schema, dc.settings)
|
||||
open = append(open, openChildEntry{
|
||||
Ref: itemRefOrSlug(*child),
|
||||
Title: child.Title,
|
||||
Status: extractFieldString(child.Fields, childDoneKey),
|
||||
CollectionSlug: child.CollectionSlug,
|
||||
})
|
||||
}
|
||||
if len(open) == 0 && hidden == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &openChildrenDetails{
|
||||
OpenChildren: open,
|
||||
HiddenBlockerCount: hidden,
|
||||
DoneField: doneKey,
|
||||
AttemptedValue: newStr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// openChildrenGuardChildVisible mirrors the visibility check used by
|
||||
// the per-parent progress endpoint (handlers_items.go around
|
||||
// `progVisIDs` / `isCollectionVisible` / `isItemVisibleToGuest`).
|
||||
// Returns true when the caller is unrestricted or when the child
|
||||
// passes both the collection-level and item-level guest filters.
|
||||
func (s *Server) openChildrenGuardChildVisible(gctx openChildrenGuardContext, child *models.Item) bool {
|
||||
// Unrestricted (admin / owner / no grant filtering in play): nil
|
||||
// visibleCollectionIDs means "see everything." Matches the
|
||||
// progress handler's convention.
|
||||
if gctx.visibleCollectionIDs == nil {
|
||||
return true
|
||||
}
|
||||
if !isCollectionVisible(child.CollectionID, gctx.visibleCollectionIDs) {
|
||||
return false
|
||||
}
|
||||
return s.isItemVisibleToGuest(gctx.r, gctx.workspaceID, child, gctx.guestFullCollIDs, gctx.guestGrantedItemIDs)
|
||||
}
|
||||
|
||||
// writeOpenChildrenError emits the 409 response with both a human
|
||||
// message AND the structured details payload. `parentRef` is the
|
||||
// already-formatted parent ref (e.g. "PLAN-12"); the message names the
|
||||
// parent + child count and points at --force. The phrasing splits on
|
||||
// whether any blockers are hidden so the human-readable line carries
|
||||
// the same signal `hidden_blocker_count` does for machines.
|
||||
func writeOpenChildrenError(w http.ResponseWriter, parentRef string, details *openChildrenDetails) {
|
||||
visible := len(details.OpenChildren)
|
||||
hidden := details.HiddenBlockerCount
|
||||
var msg string
|
||||
switch {
|
||||
case visible > 0 && hidden > 0:
|
||||
msg = fmt.Sprintf("cannot mark %s %s: %d open child(ren) still in a non-terminal state, plus %d additional you don't have access to. Pass --force to override.",
|
||||
parentRef, details.AttemptedValue, visible, hidden)
|
||||
case visible > 0:
|
||||
noun := "child"
|
||||
if visible != 1 {
|
||||
noun = "children"
|
||||
}
|
||||
msg = fmt.Sprintf("cannot mark %s %s: %d open %s still in a non-terminal state. Pass --force to override.",
|
||||
parentRef, details.AttemptedValue, visible, noun)
|
||||
default:
|
||||
// hidden > 0 only — the caller can't see any blocking children.
|
||||
noun := "child"
|
||||
if hidden != 1 {
|
||||
noun = "children"
|
||||
}
|
||||
msg = fmt.Sprintf("cannot mark %s %s: blocked by %d open %s you don't have access to. Pass --force to override (if your role permits).",
|
||||
parentRef, details.AttemptedValue, hidden, noun)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": "open_children",
|
||||
"message": msg,
|
||||
"details": details,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// asOpenChildrenGuardError unwraps a store-layer error that may carry
|
||||
// an openChildrenGuardError sentinel. Returns the sanitized details
|
||||
// and true when the error is the guard rejecting the precheck; nil +
|
||||
// false for any other error (the handler returns those via the
|
||||
// generic upstream-error path).
|
||||
func asOpenChildrenGuardError(err error) (*openChildrenDetails, bool) {
|
||||
var sentinel *openChildrenGuardError
|
||||
if errors.As(err, &sentinel) {
|
||||
return sentinel.details, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// valueInSet does a case-insensitive membership check.
|
||||
func valueInSet(v string, set []string) bool {
|
||||
low := strings.ToLower(strings.TrimSpace(v))
|
||||
for _, s := range set {
|
||||
if strings.ToLower(s) == low {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractFieldString reads a top-level scalar string from a JSON-encoded
|
||||
// fields map. Returns "" when the JSON is empty, malformed, the key is
|
||||
// missing, or the value isn't a string.
|
||||
func extractFieldString(fieldsJSON, key string) string {
|
||||
if fieldsJSON == "" || fieldsJSON == "{}" {
|
||||
return ""
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(fieldsJSON), &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := m[key].(string)
|
||||
return s
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -89,6 +89,45 @@ func (s *Store) GetCollection(id string) (*models.Collection, error) {
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// GetCollectionAnyState is GetCollection without the
|
||||
// `deleted_at IS NULL` filter — used by the open-children guard
|
||||
// (IDEA-1494 R3 P3) so a child still attached to a soft-deleted
|
||||
// collection is evaluated against ITS collection's actual done-field
|
||||
// schema instead of falling back to the default `status` terminal
|
||||
// list (which would mis-classify children of custom-done-field
|
||||
// collections as non-terminal and produce false blockers).
|
||||
//
|
||||
// Mirrors the inclusion rule baked into childrenDoneFiltersForParent /
|
||||
// doneFiltersForWorkspace, both of which already include soft-deleted
|
||||
// collections for exactly this reason.
|
||||
func (s *Store) GetCollectionAnyState(id string) (*models.Collection, error) {
|
||||
var c models.Collection
|
||||
var createdAt, updatedAt string
|
||||
var deletedAt *string
|
||||
var isDefault bool
|
||||
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id, workspace_id, name, slug, prefix, icon, description, schema, settings, sort_order, is_default, is_system, created_at, updated_at, deleted_at
|
||||
FROM collections
|
||||
WHERE id = ?
|
||||
`), id).Scan(
|
||||
&c.ID, &c.WorkspaceID, &c.Name, &c.Slug, &c.Prefix, &c.Icon, &c.Description,
|
||||
&c.Schema, &c.Settings, &c.SortOrder, &isDefault, &c.IsSystem,
|
||||
&createdAt, &updatedAt, &deletedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get collection (any state): %w", err)
|
||||
}
|
||||
c.IsDefault = isDefault
|
||||
c.CreatedAt = parseTime(createdAt)
|
||||
c.UpdatedAt = parseTime(updatedAt)
|
||||
c.DeletedAt = parseTimePtr(deletedAt)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetCollectionBySlug(workspaceID, slug string) (*models.Collection, error) {
|
||||
var id string
|
||||
err := s.db.QueryRow(s.q(`
|
||||
|
||||
+475
-8
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -272,6 +273,56 @@ func (s *Store) GetItem(id string) (*models.Item, error) {
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// getItemTx is the in-transaction variant of GetItem. Used by
|
||||
// UpdateItemWithPreCheck to re-read the parent under the workspace +
|
||||
// parent-children locks so the invariant precheck classifies the
|
||||
// transition against a snapshot that's stable for the rest of the tx
|
||||
// (Codex round-3 P2). Soft-deleted items are excluded, matching
|
||||
// GetItem's contract.
|
||||
func (s *Store) getItemTx(tx *sql.Tx, id string) (*models.Item, error) {
|
||||
var item models.Item
|
||||
var createdAt, updatedAt string
|
||||
var deletedAt *string
|
||||
var pinned bool
|
||||
|
||||
err := tx.QueryRow(s.q(`
|
||||
SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags,
|
||||
i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order,
|
||||
i.created_by, i.last_modified_by, i.source,
|
||||
i.item_number, i.seq, i.created_at, i.updated_at, i.deleted_at,
|
||||
c.slug, c.name, c.icon, c.prefix,
|
||||
COALESCE(au.name, ''), COALESCE(au.email, ''),
|
||||
COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, '')
|
||||
FROM items i
|
||||
JOIN collections c ON c.id = i.collection_id
|
||||
LEFT JOIN users au ON au.id = i.assigned_user_id
|
||||
LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id
|
||||
WHERE i.id = ? AND i.deleted_at IS NULL
|
||||
`), id).Scan(
|
||||
&item.ID, &item.WorkspaceID, &item.CollectionID, &item.Title, &item.Slug,
|
||||
&item.Content, &item.Fields, &item.Tags,
|
||||
&pinned, &item.SortOrder, &item.ParentID, &item.AssignedUserID, &item.AgentRoleID, &item.RoleSortOrder,
|
||||
&item.CreatedBy, &item.LastModifiedBy, &item.Source,
|
||||
&item.ItemNumber, &item.Seq, &createdAt, &updatedAt, &deletedAt,
|
||||
&item.CollectionSlug, &item.CollectionName, &item.CollectionIcon, &item.CollectionPrefix,
|
||||
&item.AssignedUserName, &item.AssignedUserEmail,
|
||||
&item.AgentRoleName, &item.AgentRoleSlug, &item.AgentRoleIcon,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get item (tx): %w", err)
|
||||
}
|
||||
|
||||
item.Pinned = pinned
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
item.UpdatedAt = parseTime(updatedAt)
|
||||
item.DeletedAt = parseTimePtr(deletedAt)
|
||||
hydrateItemComputedMetadata(&item)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetItemBySlug(workspaceID, slug string) (*models.Item, error) {
|
||||
var id string
|
||||
err := s.db.QueryRow(s.q(`
|
||||
@@ -1285,6 +1336,29 @@ func (s *Store) listItemsFTS(workspaceID string, params models.ItemListParams) (
|
||||
}
|
||||
|
||||
func (s *Store) UpdateItem(id string, input models.ItemUpdate) (*models.Item, error) {
|
||||
return s.UpdateItemWithPreCheck(id, input, nil)
|
||||
}
|
||||
|
||||
// UpdateItemWithPreCheck is UpdateItem with an optional pre-mutation
|
||||
// hook that runs inside the same transaction (and, on Postgres, holds
|
||||
// the same workspace advisory lock) as the update itself. Callers can
|
||||
// use the hook to enforce cross-row invariants whose decision must be
|
||||
// atomic with the write — e.g. the open-children guard (IDEA-1494)
|
||||
// needs the children-list query and the parent's status flip to share
|
||||
// a tx so a concurrent child insert / child status change can't slip
|
||||
// between them.
|
||||
//
|
||||
// The hook receives the transaction and the freshly-read existing
|
||||
// item. Returning a non-nil error rolls the tx back and surfaces the
|
||||
// error verbatim — callers can return a sentinel and `errors.Is` it
|
||||
// in the handler.
|
||||
//
|
||||
// Pass a nil precheck for the standard, unchecked update path.
|
||||
func (s *Store) UpdateItemWithPreCheck(
|
||||
id string,
|
||||
input models.ItemUpdate,
|
||||
precheck func(tx *sql.Tx, existing *models.Item) error,
|
||||
) (*models.Item, error) {
|
||||
existing, err := s.GetItem(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1310,6 +1384,66 @@ func (s *Store) UpdateItem(id string, input models.ItemUpdate) (*models.Item, er
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// IDEA-1494 round 2: also acquire the parent-children advisory
|
||||
// lock for THIS item (as a potential parent) AND for its own
|
||||
// parent (when it is itself a child). That gives the open-children
|
||||
// guard a tight serialization:
|
||||
//
|
||||
// - A parent's UpdateItem precheck holds `pad:parent-children:<parent_id>`
|
||||
// while reading the children list and writing the parent.
|
||||
// - A child's UpdateItem holds the same key for its parent
|
||||
// while it writes itself.
|
||||
//
|
||||
// Result: a child status-flip that would invalidate the parent's
|
||||
// guard cannot interleave between the parent's children-read and
|
||||
// the parent's status-write. SQLite gets this for free from
|
||||
// BEGIN IMMEDIATE; Postgres needs the explicit advisory lock.
|
||||
//
|
||||
// Lock ordering: workspace lock → THIS item's parent lock → THIS
|
||||
// item's own children lock. Both lock keys are namespaced under
|
||||
// `pad:parent-children:` so they only contend on the parent ID;
|
||||
// acquiring two distinct keys in a fixed order can't deadlock.
|
||||
if err := s.acquireParentChildrenLocksForUpdate(tx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// IDEA-1494 round 2: run the caller's invariant check (if any)
|
||||
// AFTER the locks are held but BEFORE any mutation. Closing the
|
||||
// guard-vs-write TOCTOU window relies on this ordering — the
|
||||
// precheck's view of `items` / `item_links` is the same one the
|
||||
// UPDATE below will write against because every concurrent
|
||||
// UpdateItem on this parent (or on any of its children) blocks
|
||||
// on the same advisory key.
|
||||
//
|
||||
// Codex round-3 P2: re-read the item INSIDE the tx (after locks)
|
||||
// and pass that fresh snapshot to the precheck. The pre-tx
|
||||
// `existing` above was loaded without holding the workspace seq
|
||||
// lock or the parent-children lock — a concurrent writer could
|
||||
// have flipped the parent's done-field between that read and
|
||||
// here, which would mis-classify the transition (false-fire or
|
||||
// false-skip). The post-lock re-read sees what the UPDATE will
|
||||
// write against.
|
||||
if precheck != nil {
|
||||
freshExisting, ferr := s.getItemTx(tx, id)
|
||||
if ferr != nil {
|
||||
return nil, fmt.Errorf("re-read item under lock: %w", ferr)
|
||||
}
|
||||
if freshExisting == nil {
|
||||
// Item was deleted between the pre-tx read and the
|
||||
// post-lock re-read. Treat as not-found and let the
|
||||
// handler surface a 404. Returning nil here mirrors the
|
||||
// existing == nil branch above.
|
||||
return nil, nil
|
||||
}
|
||||
if err := precheck(tx, freshExisting); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Use the fresh snapshot for the rest of the function too —
|
||||
// otherwise the mutation logic below would proceed from
|
||||
// stale data and undo the integrity gain.
|
||||
existing = freshExisting
|
||||
}
|
||||
|
||||
ts := now()
|
||||
|
||||
// Create version if content is changing
|
||||
@@ -1578,6 +1712,25 @@ func (s *Store) RestoreItem(id string) (*models.Item, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Codex round-3 P1 / round-4 P1: restoring an item resurrects it
|
||||
// as a (potentially non-terminal) child of EVERY parent it's
|
||||
// linked to (one item can have both a `parent` and an
|
||||
// `implements` link). Lock ALL of those parents' children-keys
|
||||
// via the canonical sorted-multi-lock helper so concurrent
|
||||
// UpdateItemWithPreCheck callers on any of them see this
|
||||
// resurrection in their post-lock snapshots.
|
||||
//
|
||||
// Pre-fix this called AcquireParentChildrenLock for a single
|
||||
// LIMIT 1 row — a multi-parent child would have left another
|
||||
// parent's precheck racing the resurrection.
|
||||
parentIDs, err := s.listParentChildLockKeys(tx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.AcquireParentChildrenLocks(tx, parentIDs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ts := now()
|
||||
result, err := tx.Exec(s.q(`
|
||||
UPDATE items SET deleted_at = NULL, updated_at = ?, seq = `+nextWorkspaceSeqSubquery+`
|
||||
@@ -1727,14 +1880,36 @@ func (s *Store) CreateItemLink(workspaceID string, input models.ItemLinkCreate,
|
||||
createdBy = "user"
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(s.q(`
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Codex round-3 P1: when this link puts `sourceID` into the
|
||||
// children-set of `target` (i.e. linkType ∈ childLinkTypes), lock
|
||||
// the target's parent-children key so a concurrent
|
||||
// UpdateItemWithPreCheck on the target can't read 0 open children
|
||||
// while we're about to attach a non-terminal one. Non-child link
|
||||
// types (blocks, supersedes, …) don't affect the children-set so
|
||||
// we skip the lock — keeps the common case lock-free.
|
||||
if isChildLinkType(linkType) {
|
||||
if err := s.AcquireParentChildrenLocks(tx, input.TargetID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(s.q(`
|
||||
INSERT INTO item_links (id, workspace_id, source_id, target_id, link_type, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`), id, workspaceID, sourceID, input.TargetID, linkType, createdBy, ts)
|
||||
if err != nil {
|
||||
`), id, workspaceID, sourceID, input.TargetID, linkType, createdBy, ts); err != nil {
|
||||
return nil, fmt.Errorf("create item link: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit create item link: %w", err)
|
||||
}
|
||||
|
||||
return s.getItemLink(id)
|
||||
}
|
||||
|
||||
@@ -1882,7 +2057,37 @@ func (s *Store) GetItemLinkByID(id string) (*models.ItemLink, error) {
|
||||
}
|
||||
|
||||
func (s *Store) DeleteItemLink(id string) error {
|
||||
result, err := s.db.Exec(s.q("DELETE FROM item_links WHERE id = ?"), id)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Codex round-3 P1: peek the link's type + target before deleting
|
||||
// so we can lock the target's parent-children key when this link
|
||||
// participates in the children-set. Without this, a concurrent
|
||||
// UpdateItemWithPreCheck on the target could read the child as
|
||||
// still attached, decide the parent has no open children, and
|
||||
// commit a terminal status while we orphan a non-terminal child.
|
||||
//
|
||||
// We DON'T lock for non-child link types (blocks, supersedes, …)
|
||||
// — they don't affect the children-set, so contention there is
|
||||
// unnecessary.
|
||||
var linkType, targetID string
|
||||
err = tx.QueryRow(s.q("SELECT link_type, target_id FROM item_links WHERE id = ?"), id).Scan(&linkType, &targetID)
|
||||
if err == sql.ErrNoRows {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("peek item link for delete: %w", err)
|
||||
}
|
||||
if isChildLinkType(linkType) {
|
||||
if err := s.AcquireParentChildrenLocks(tx, targetID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
result, err := tx.Exec(s.q("DELETE FROM item_links WHERE id = ?"), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete item link: %w", err)
|
||||
}
|
||||
@@ -1890,7 +2095,7 @@ func (s *Store) DeleteItemLink(id string) error {
|
||||
if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// --- Phase Links ---
|
||||
@@ -1898,6 +2103,13 @@ func (s *Store) DeleteItemLink(id string) error {
|
||||
// SetParentLink sets the parent for an item. Since an item can belong to at most
|
||||
// one parent, this deletes any existing parent link for the item first.
|
||||
// Includes cycle detection to prevent A→B→A or deeper ancestor loops.
|
||||
//
|
||||
// Codex round-3 P1: acquires `pad:parent-children:<id>` for BOTH the
|
||||
// old parent (if any) AND the new parent in sorted order. That makes
|
||||
// a concurrent UpdateItemWithPreCheck on either parent block on the
|
||||
// same key, closing the link-mutation TOCTOU gap — without this, the
|
||||
// guard could read 0 open children while this method was about to
|
||||
// attach a non-terminal child.
|
||||
func (s *Store) SetParentLink(workspaceID, itemID, parentID, createdBy string) (*models.ItemLink, error) {
|
||||
// Cycle detection: walk the ancestor chain from parentID to ensure itemID is not an ancestor.
|
||||
if err := s.checkParentCycle(itemID, parentID); err != nil {
|
||||
@@ -1910,6 +2122,25 @@ func (s *Store) SetParentLink(workspaceID, itemID, parentID, createdBy string) (
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Find the existing parent (if any) so we can lock against it too.
|
||||
// The DELETE below targets link_type='parent' specifically, which
|
||||
// matches what the guard's children query treats as the parent
|
||||
// edge (childLinkTypes includes 'parent'); other child-link types
|
||||
// like 'implements' aren't displaced by this method so we don't
|
||||
// need their old parent here.
|
||||
var oldParentID sql.NullString
|
||||
if err := tx.QueryRow(s.q(`
|
||||
SELECT target_id FROM item_links
|
||||
WHERE source_id = ? AND link_type = 'parent'
|
||||
LIMIT 1
|
||||
`), itemID).Scan(&oldParentID); err != nil && err != sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("lookup existing parent: %w", err)
|
||||
}
|
||||
|
||||
if err := s.AcquireParentChildrenLocks(tx, oldParentID.String, parentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Delete existing parent link for this item (if any)
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM item_links WHERE source_id = ? AND link_type = 'parent'`), itemID); err != nil {
|
||||
return nil, fmt.Errorf("delete existing parent link: %w", err)
|
||||
@@ -1961,12 +2192,36 @@ func (s *Store) checkParentCycle(itemID, parentID string) error {
|
||||
}
|
||||
|
||||
// ClearParentLink removes the parent link for an item.
|
||||
//
|
||||
// Codex round-3 P1: runs in a tx and acquires `pad:parent-children:<old>`
|
||||
// before the DELETE so a concurrent UpdateItemWithPreCheck on the old
|
||||
// parent blocks until this commit. Detaching a child is materially
|
||||
// similar to attaching one — the parent's children-set changes either
|
||||
// way and the guard must see a consistent view.
|
||||
func (s *Store) ClearParentLink(itemID string) error {
|
||||
_, err := s.db.Exec(s.q(`DELETE FROM item_links WHERE source_id = ? AND link_type = 'parent'`), itemID)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var oldParentID sql.NullString
|
||||
if err := tx.QueryRow(s.q(`
|
||||
SELECT target_id FROM item_links
|
||||
WHERE source_id = ? AND link_type = 'parent'
|
||||
LIMIT 1
|
||||
`), itemID).Scan(&oldParentID); err != nil && err != sql.ErrNoRows {
|
||||
return fmt.Errorf("lookup parent for clear: %w", err)
|
||||
}
|
||||
if oldParentID.Valid && oldParentID.String != "" {
|
||||
if err := s.AcquireParentChildrenLocks(tx, oldParentID.String); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM item_links WHERE source_id = ? AND link_type = 'parent'`), itemID); err != nil {
|
||||
return fmt.Errorf("clear parent link: %w", err)
|
||||
}
|
||||
return nil
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetParentForItem returns the parent link for an item, or nil if it has no parent.
|
||||
@@ -2303,7 +2558,181 @@ func (s *Store) GetAllItemProgress(workspaceID, collectionSlug string) ([]ItemPr
|
||||
// GetChildItems returns all non-deleted child items linked to the given parent
|
||||
// via item_links. Returns children from any collection.
|
||||
func (s *Store) GetChildItems(parentItemID string) ([]models.Item, error) {
|
||||
rows, err := s.db.Query(s.q(fmt.Sprintf(`
|
||||
return s.getChildItems(s.db, parentItemID)
|
||||
}
|
||||
|
||||
// GetChildItemsTx is the in-transaction variant of GetChildItems. The
|
||||
// underlying query is the same as GetChildItems; using a *sql.Tx ties
|
||||
// the read to the caller's transaction so it sees the same snapshot
|
||||
// the subsequent UPDATE will write against (IDEA-1494 R2).
|
||||
//
|
||||
// Atomicity vs. concurrent child mutations is provided by the caller's
|
||||
// transaction-scoped locking:
|
||||
//
|
||||
// - SQLite: db-wide BEGIN IMMEDIATE write lock (set globally via
|
||||
// `_txlock=immediate`) serializes all writers, so any concurrent
|
||||
// child insert / child update blocks until this tx commits or
|
||||
// rolls back. No additional locking is needed.
|
||||
// - Postgres: the caller is expected to hold a parent-keyed advisory
|
||||
// lock (see AcquireParentChildrenLocks below) so concurrent
|
||||
// mutations on the same parent's children are serialized against
|
||||
// this read.
|
||||
//
|
||||
// FOR UPDATE is intentionally NOT used — the underlying SELECT carries
|
||||
// DISTINCT (necessary because item_links can carry both `parent` and
|
||||
// the legacy `plan` link_type for the same edge), and Postgres rejects
|
||||
// `SELECT DISTINCT … FOR UPDATE`. The advisory-lock pattern sidesteps
|
||||
// that constraint while still giving us a serialized snapshot.
|
||||
func (s *Store) GetChildItemsTx(tx *sql.Tx, parentItemID string) ([]models.Item, error) {
|
||||
if tx == nil {
|
||||
return s.GetChildItems(parentItemID)
|
||||
}
|
||||
return s.getChildItems(tx, parentItemID)
|
||||
}
|
||||
|
||||
// acquireParentChildrenLocksForUpdate is the in-tx helper UpdateItem
|
||||
// uses to serialize itself against the open-children guard
|
||||
// (IDEA-1494 R2 / R4). It acquires the parent-children advisory lock
|
||||
// for:
|
||||
//
|
||||
// 1. EVERY parent the item is currently a child of (via item_links
|
||||
// of type ∈ childLinkTypes — `parent` AND `implements`).
|
||||
// childLinkTypes is the inclusion rule GetChildItems walks, so
|
||||
// this set is exactly the parents whose guard precheck could see
|
||||
// this item as a child.
|
||||
// 2. THIS item itself, as a parent — so any concurrent precheck
|
||||
// running against this item's own children list waits.
|
||||
//
|
||||
// Codex round-4 P1: pre-fix this helper used `LIMIT 1` and only
|
||||
// locked one parent. A child of TWO parents (one via `parent`, one
|
||||
// via `implements`) would let a status flip race against the
|
||||
// un-locked parent's precheck. The query now returns ALL distinct
|
||||
// parent target_ids and we lock every one.
|
||||
//
|
||||
// All keys (parents + self) are funnelled through
|
||||
// AcquireParentChildrenLocks so they're acquired in a single,
|
||||
// canonical sorted order — round-4 P2's deadlock-avoidance contract.
|
||||
// No call site outside this helper takes pad:parent-children:* locks
|
||||
// in any other order.
|
||||
func (s *Store) acquireParentChildrenLocksForUpdate(tx *sql.Tx, itemID string) error {
|
||||
if s.dialect.Driver() != DriverPostgres {
|
||||
return nil
|
||||
}
|
||||
parentIDs, err := s.listParentChildLockKeys(tx, itemID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys := append(parentIDs, itemID)
|
||||
return s.AcquireParentChildrenLocks(tx, keys...)
|
||||
}
|
||||
|
||||
// listParentChildLockKeys returns every target_id this item is the
|
||||
// `source_id` of under a childLinkTypes link — i.e. every parent
|
||||
// whose children-set includes this item. Used wherever we need to
|
||||
// lock all of an item's parents at once (UpdateItem, RestoreItem,
|
||||
// link-mutation paths).
|
||||
//
|
||||
// IMPORTANT: this MUST stay in lockstep with childLinkTypes (the
|
||||
// inclusion rule GetChildItems uses). If a new link type joins the
|
||||
// children-set, both the query here and the read query must add it
|
||||
// together so lock coverage matches read coverage.
|
||||
func (s *Store) listParentChildLockKeys(tx *sql.Tx, itemID string) ([]string, error) {
|
||||
rows, err := tx.Query(s.q(fmt.Sprintf(`
|
||||
SELECT DISTINCT target_id FROM item_links
|
||||
WHERE source_id = ? AND link_type IN (%s)
|
||||
`, childLinkTypeSQL())), itemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list parent lock keys: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("scan parent lock key: %w", err)
|
||||
}
|
||||
if id == "" || id == itemID {
|
||||
continue
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AcquireParentChildrenLocks is the CANONICAL helper for taking
|
||||
// `pad:parent-children:<id>` advisory locks. Every call site that
|
||||
// needs to serialize against the open-children guard MUST go through
|
||||
// this function — UpdateItemWithPreCheck precheck, MoveItemWithPreCheck
|
||||
// precheck, RestoreItem, SetParentLink, ClearParentLink,
|
||||
// CreateItemLink (child-link types), DeleteItemLink (child-link
|
||||
// types). Ad-hoc single-key acquisition outside this helper is
|
||||
// FORBIDDEN — two call sites taking distinct keys in different
|
||||
// orders WILL deadlock under contention (the classic AB/BA shape).
|
||||
//
|
||||
// The contract this helper enforces:
|
||||
//
|
||||
// 1. Deduplicate. Repeated IDs in the input collapse to one lock.
|
||||
// 2. Drop empties. "" / nil entries don't get locked.
|
||||
// 3. Acquire in canonical sorted order (string-sort by ID).
|
||||
// Two concurrent callers that share any subset of IDs always
|
||||
// grab the overlap in the same order → no deadlock.
|
||||
//
|
||||
// SQLite is a no-op because the global BEGIN IMMEDIATE write lock
|
||||
// (set via _txlock=immediate in store.go) already serializes every
|
||||
// writer; advisory locks would add no protection there.
|
||||
//
|
||||
// Per Codex round-3 P1 (link-mutations bypass) + round-4 P2
|
||||
// (lock-order asymmetry). If you find yourself writing
|
||||
// `pg_advisory_xact_lock(... 'pad:parent-children:' ...)` anywhere
|
||||
// outside this helper, route it through here instead.
|
||||
func (s *Store) AcquireParentChildrenLocks(tx *sql.Tx, parentItemIDs ...string) error {
|
||||
if s.dialect.Driver() != DriverPostgres {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(parentItemIDs))
|
||||
keys := make([]string, 0, len(parentItemIDs))
|
||||
for _, id := range parentItemIDs {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
keys = append(keys, id)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if _, err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext('pad:parent-children:' || $1))", k); err != nil {
|
||||
return fmt.Errorf("acquire parent-children lock %q: %w", k, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isChildLinkType reports whether the given link type is one the
|
||||
// open-children guard counts toward the children-set (i.e. matches
|
||||
// the inclusion rule baked into `childLinkTypes` and used by
|
||||
// GetChildItems via `childLinkTypeSQL()`). Single source of truth so
|
||||
// link-writer lock acquisition can't drift from the read query's set.
|
||||
func isChildLinkType(linkType string) bool {
|
||||
for _, t := range childLinkTypes {
|
||||
if t == linkType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// childQueryer is the small surface the children-list query needs from
|
||||
// either *sql.DB or *sql.Tx. Lets getChildItems serve both the unlocked
|
||||
// and tx-bound paths from one implementation.
|
||||
type childQueryer interface {
|
||||
Query(query string, args ...any) (*sql.Rows, error)
|
||||
}
|
||||
|
||||
func (s *Store) getChildItems(q childQueryer, parentItemID string) ([]models.Item, error) {
|
||||
rows, err := q.Query(s.q(fmt.Sprintf(`
|
||||
SELECT DISTINCT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags,
|
||||
i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order,
|
||||
i.created_by, i.last_modified_by, i.source,
|
||||
@@ -2382,6 +2811,26 @@ func (s *Store) PopulateHasChildren(items []models.Item) {
|
||||
// client polling /items-changes?since=cursor would render the item
|
||||
// under its old collection until a full refresh.
|
||||
func (s *Store) MoveItem(itemID, targetCollectionID, newFieldsJSON string) (*models.Item, error) {
|
||||
return s.MoveItemWithPreCheck(itemID, targetCollectionID, newFieldsJSON, nil)
|
||||
}
|
||||
|
||||
// MoveItemWithPreCheck is MoveItem with the same precheck escape hatch
|
||||
// UpdateItemWithPreCheck offers. Codex round-3 P1: a `pad item move
|
||||
// ... --field status=done` writes a terminal done-field value through
|
||||
// MoveItem, bypassing the open-children guard wired into the regular
|
||||
// UpdateItem path. This variant runs the caller's invariant check
|
||||
// inside the move's transaction, after acquiring the workspace seq
|
||||
// lock + the parent-children lock for this item's own parent (it CAN
|
||||
// itself be a parent — children stay attached across collection
|
||||
// changes — so we lock for itself too, matching
|
||||
// acquireParentChildrenLocksForUpdate's shape).
|
||||
//
|
||||
// The precheck receives a fresh in-tx snapshot of the item, same as
|
||||
// UpdateItemWithPreCheck (the pre-tx `existing` is replaced).
|
||||
func (s *Store) MoveItemWithPreCheck(
|
||||
itemID, targetCollectionID, newFieldsJSON string,
|
||||
precheck func(tx *sql.Tx, existing *models.Item) error,
|
||||
) (*models.Item, error) {
|
||||
existing, err := s.GetItem(itemID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2400,6 +2849,24 @@ func (s *Store) MoveItem(itemID, targetCollectionID, newFieldsJSON string) (*mod
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.acquireParentChildrenLocksForUpdate(tx, itemID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if precheck != nil {
|
||||
freshExisting, ferr := s.getItemTx(tx, itemID)
|
||||
if ferr != nil {
|
||||
return nil, fmt.Errorf("re-read item under lock: %w", ferr)
|
||||
}
|
||||
if freshExisting == nil {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
if err := precheck(tx, freshExisting); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing = freshExisting
|
||||
}
|
||||
|
||||
_, err = tx.Exec(s.q(`
|
||||
UPDATE items
|
||||
SET collection_id = ?, fields = ?, updated_at = ?, seq = `+nextWorkspaceSeqSubquery+`
|
||||
|
||||
Reference in New Issue
Block a user