Files
pad/internal/models/item.go
T
xarmian 438cb6180a fix(mcp): flexible JSON shapes on item create + clearer field surface (BUG-1431, BUG-1432) (#547)
* fix(mcp): flexible JSON shapes on item create + clearer field surface (BUG-1431, BUG-1432)

BUG-1432 root cause (real): models.ItemCreate.Tags is a Go string, so
the default unmarshaler rejected the natural JSON-array shape every
agent sends (`tags: ["foo","bar"]` → "cannot unmarshal array into Go
struct field ItemCreate.tags of type string", HTTP 400). On Postgres
the alternative — passing `tags: "foo,bar"` per the catalog's old
"Comma-separated tags" description — landed as a non-JSON value in
the JSONB column and surfaced as a generic HTTP 500. SQLite's TEXT
column silently accepted the corrupt value, which is why local repros
didn't show it.

Codex's independent investigation called out the asymmetry: ItemUpdate
already had a flexible UnmarshalJSON for `fields`/`tags` per BUG-1144,
but ItemCreate didn't. This PR mirrors that flexibility on the create
path and aligns the MCP surface description with reality.

BUG-1431 root cause (real, not the misdiagnosis the agent reported):
the dispatcher's `parseFieldKVP` only accepted the CLI-style array-of-
"key=value" shape, rejecting the JSON-native `field: {key: value}` map
shape with "expected array or string, got map[string]interface {}".
Agents naturally try the map shape and got a non-actionable error;
that drove the BUG-1409 agent to mis-blame status placement. Empirical
repro confirmed that `status` actually works in both top-level AND
inside-fields positions today (Tests 1, 4 in the investigation); the
real surface problem was the missing map shape on `field`.

Changes:

- internal/models/item.go: add UnmarshalJSON to ItemCreate mirroring
  ItemUpdate's BUG-1144 pattern. Accepts `fields` as object or
  JSON-encoded string; `tags` as array or JSON-encoded string; either
  field absent / null leaves Go zero value. Wrong shapes surface
  ErrInvalidFieldsType / ErrInvalidTagsType (existing sentinels) so
  agents see clean domain errors instead of "Go struct field" leaks.

- internal/mcp/dispatch_http.go: parseFieldKVP now accepts
  map[string]any in addition to the existing array/string shapes. Map
  shape preserves non-string values verbatim (e.g. number from a typed
  flag), matching the array path's existing pass-through for non-string
  entries.

- internal/mcp/catalog_item.go: update `tags` description from
  "Comma-separated tags" (wrong on both SQLite and Postgres) to
  "Tags as a JSON array of strings, e.g. [\"v1\",\"frontend\"]". Update
  `field` description to clarify it's the escape hatch for
  SCHEMA-DECLARED custom fields, name the dedicated top-level params
  agents should reach for instead (status/priority/category/parent/
  role/assign/tags), and note the new map-shape acceptance. Tool-level
  prose updated to match.

Tests:

- TestItemCreateUnmarshalFlexFields (mirror of
  TestItemUpdateUnmarshalFlexFields): 9 cases covering array/string/
  null/absent/wrong-shape tags + object/string/array fields, plus a
  smoke test that other fields decode normally alongside the new
  flex paths.

- TestParseFieldKVP_Variants: extended with 3 new map-shape cases
  (basic map, empty-key-skipped, non-string-value preserved).

End-to-end verification: 5 input shapes via curl against the live
handler. Pre-fix `tags: ["foo","bar"]` returned HTTP 400; post-fix
returns HTTP 201 with `tags="[\"foo\",\"bar\"]"` in the column.
`tags: {x:1}` (wrong shape) now returns a clean
domain-level 400 instead of leaked Go internals. Existing back-compat
paths (JSON-encoded string forms) preserved.

Related: PR #546 (BUG-1430 rate limit) addressed the original 500
cascade that drove the agent's specific misdiagnoses in BUG-1409.

* fix(mcp): forward tags array on update + drop unsupported map-shape doc per Codex review (round 1)

Codex round 1 caught two issues:

[P1] dispatch_http_advanced.go's PATCH builder filtered on `string`
only when forwarding `tags`, so a schema-conforming
`pad_item.update tags: ["a","b"]` was silently dropped. Now forwards
verbatim like mapItemCreate does — the handler's ItemUpdate
flex-unmarshaler (BUG-1144) normalizes any shape downstream.
Regression test added.

[P2] The `field` description claimed `{key: value}` map shape was
accepted, but the schema Type stays `array<string>` so schema-following
clients won't send the map shape. parseFieldKVP's map-shape handling
(added in the previous commit) stays as defensive parsing for clients
that ignore the schema, but the description no longer promises a shape
the published schema doesn't advertise. Tool-level prose updated to
match.

* fix(mcp): revert speculative parseFieldKVP map-shape support per Codex review (round 2)

Codex round 2 [P2] pointed out the map-shape parseFieldKVP support
added in the first commit is dead code in practice:

1. The advertised schema for `field` is `array<string>` — no
   schema-conforming client sends a map.
2. `BuildCLIArgs` rejects map-shaped repeatable flags before they
   reach the HTTP dispatcher.
3. Even if a map did reach the dispatcher, `hasFieldChanges`
   doesn't recognize map shapes as field changes — `pad_item.update
   field: {effort: "l"}` would skip the merge and PATCH without
   `fields`.

Either completing the support (fix hasFieldChanges + BuildCLIArgs +
ItemUpdate Unmarshal) OR reverting was the right call. Reverting
keeps the surface consistent with the schema and removes the
unreachable code; future agents who want to override fields can use
the documented `["key=value"]` array shape.

BUG-1431's functional fix lands as the catalog description tightening
(the empirical repro confirmed `status` placement already works in
both forms; the agent's misdiagnosis was rooted in unclear docs, not
broken code). BUG-1432's flexible JSON unmarshal on ItemCreate stays
— that's the real fix verified by the live-handler repro.

* fix(mcp): preserve empty-string tags no-op + table-driven test per Codex review (round 3)

Codex round 3 [P2] caught a regression introduced in round 1's fix: by
switching the tags forwarding guard from \`v.(string) && v != ""\` to
\`v != nil\` to support array shapes, the empty-string filter for tags
on update was lost. \`pad_item.update tags: ""\` would now forward an
empty string to ItemUpdate, which treats it as an explicit
empty-string write — corrupting the JSON/JSONB tags column (500 on
Postgres).

Fix: type-switch on tags. Empty string skips (matches pre-fix
behaviour); arrays (including empty array \`[]\`, the legitimate
"clear tags" case) and non-empty strings forward.

Tests: the single-shape array test is replaced with a table-driven
TestDispatchItemUpdate_TagsForwarding covering array, empty array,
empty string (no-op), and comma-separated back-compat. Each case
asserts the tags key's presence/absence and shape in the PATCH body.
2026-05-14 18:36:18 -04:00

792 lines
27 KiB
Go

package models
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"time"
)
const (
ItemFieldGitHubPR = "github_pr"
ItemFieldImplementationNotes = "implementation_notes"
ItemFieldDecisionLog = "decision_log"
ItemFieldConvention = "convention"
)
type Item struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
CollectionID string `json:"collection_id"`
Title string `json:"title"`
Slug string `json:"slug"`
Ref string `json:"ref,omitempty"` // computed: e.g. "TASK-5", "BUG-8"
Content string `json:"content"`
Fields string `json:"fields"` // JSON string
Tags string `json:"tags"` // JSON array string
Pinned bool `json:"pinned"`
SortOrder int `json:"sort_order"`
ParentID *string `json:"parent_id,omitempty"`
CreatedBy string `json:"created_by"`
LastModifiedBy string `json:"last_modified_by"`
Source string `json:"source"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
// Assignment: (user, role) pair
AssignedUserID *string `json:"assigned_user_id,omitempty"`
AgentRoleID *string `json:"agent_role_id,omitempty"`
RoleSortOrder int `json:"role_sort_order"`
// Auto-assigned sequential number within collection
ItemNumber *int `json:"item_number,omitempty"`
// Seq is a workspace-scoped monotonically-increasing sequence number
// stamped on every mutation (create / update / soft-delete /
// restore). It is the cursor mechanic for the local-first read
// model's delta sync (PLAN-1343, DOC-1342 design decision #1).
// Clients track the max seq they have seen and request
// `?since=<seq>` deltas to resume. Robust against clock-skew /
// same-millisecond-write / NTP-step correctness holes that an
// `updated_at` watermark would carry.
Seq int64 `json:"seq,omitempty"`
// Populated by joins (not stored)
AssignedUserName string `json:"assigned_user_name,omitempty"`
AssignedUserEmail string `json:"assigned_user_email,omitempty"`
AgentRoleName string `json:"agent_role_name,omitempty"`
AgentRoleSlug string `json:"agent_role_slug,omitempty"`
AgentRoleIcon string `json:"agent_role_icon,omitempty"`
CollectionSlug string `json:"collection_slug,omitempty"`
CollectionName string `json:"collection_name,omitempty"`
CollectionIcon string `json:"collection_icon,omitempty"`
CollectionPrefix string `json:"collection_prefix,omitempty"`
// Parent link (populated by enrichItemForResponse / enrichItemsWithParent)
ParentLinkID string `json:"parent_link_id,omitempty"`
ParentRef string `json:"parent_ref,omitempty"`
ParentTitle string `json:"parent_title,omitempty"`
ParentSlug string `json:"parent_slug,omitempty"`
ParentCollectionSlug string `json:"parent_collection_slug,omitempty"`
// HasChildren is true if this item has child items linked to it.
// Populated by enrichment, not stored in the DB.
HasChildren bool `json:"has_children,omitempty"`
DerivedClosure *ItemDerivedClosure `json:"derived_closure,omitempty"`
CodeContext *ItemCodeContext `json:"code_context,omitempty"`
Convention *ItemConventionMetadata `json:"convention,omitempty"`
ImplementationNotes []ItemImplementationNote `json:"implementation_notes,omitempty"`
DecisionLog []ItemDecisionLogEntry `json:"decision_log,omitempty"`
}
// ComputeRef sets the Ref field from CollectionPrefix and ItemNumber.
// Call this after populating the item from a database query.
func (item *Item) ComputeRef() {
if item.CollectionPrefix != "" && item.ItemNumber != nil {
item.Ref = fmt.Sprintf("%s-%d", item.CollectionPrefix, *item.ItemNumber)
}
}
type ItemRelationRef struct {
ID string `json:"id"`
Slug string `json:"slug,omitempty"`
Ref string `json:"ref,omitempty"`
Title string `json:"title"`
CollectionSlug string `json:"collection_slug,omitempty"`
Status string `json:"status,omitempty"`
}
type ItemDerivedClosure struct {
IsClosed bool `json:"is_closed"`
Kind string `json:"kind"`
Summary string `json:"summary"`
RelatedItems []ItemRelationRef `json:"related_items,omitempty"`
}
type ItemCodeContext struct {
Provider string `json:"provider"`
Repo string `json:"repo,omitempty"`
Branch string `json:"branch,omitempty"`
PullRequest *ItemPullRequestMetadata `json:"pull_request,omitempty"`
}
type ItemConventionMetadata struct {
Category string `json:"category,omitempty"`
Trigger string `json:"trigger,omitempty"`
Surfaces []string `json:"surfaces,omitempty"`
Enforcement string `json:"enforcement,omitempty"`
Commands []string `json:"commands,omitempty"`
}
type ItemPullRequestMetadata struct {
Number int `json:"number"`
URL string `json:"url"`
Title string `json:"title"`
State string `json:"state"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type ItemImplementationNote struct {
ID string `json:"id,omitempty"`
Summary string `json:"summary"`
Details string `json:"details,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
CreatedBy string `json:"created_by,omitempty"`
}
type ItemDecisionLogEntry struct {
ID string `json:"id,omitempty"`
Decision string `json:"decision"`
Rationale string `json:"rationale,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
CreatedBy string `json:"created_by,omitempty"`
}
type githubPRFields struct {
Number int `json:"number"`
URL string `json:"url"`
Title string `json:"title"`
State string `json:"state"`
Branch string `json:"branch"`
Repo string `json:"repo"`
UpdatedAt string `json:"updated_at"`
}
type conventionFields struct {
Category string `json:"category"`
Trigger string `json:"trigger"`
Surfaces []string `json:"surfaces"`
Enforcement string `json:"enforcement"`
Commands []string `json:"commands"`
}
func ExtractItemCodeContext(fieldsJSON string) *ItemCodeContext {
fieldsMap, ok := parseItemFields(fieldsJSON)
if !ok {
return nil
}
raw, ok := fieldsMap[ItemFieldGitHubPR]
if !ok {
return nil
}
payload, err := json.Marshal(raw)
if err != nil {
return nil
}
var githubPR githubPRFields
if err := json.Unmarshal(payload, &githubPR); err != nil {
return nil
}
if githubPR.Number == 0 && githubPR.URL == "" && githubPR.Branch == "" && githubPR.Repo == "" {
return nil
}
context := &ItemCodeContext{
Provider: "github",
Repo: githubPR.Repo,
Branch: githubPR.Branch,
}
if githubPR.Number != 0 || githubPR.URL != "" || githubPR.Title != "" || githubPR.State != "" {
context.PullRequest = &ItemPullRequestMetadata{
Number: githubPR.Number,
URL: githubPR.URL,
Title: githubPR.Title,
State: githubPR.State,
UpdatedAt: githubPR.UpdatedAt,
}
}
return context
}
func ExtractItemConventionMetadata(fieldsJSON string) *ItemConventionMetadata {
fieldsMap, ok := parseItemFields(fieldsJSON)
if !ok {
return nil
}
var metadata ItemConventionMetadata
hasMetadata := false
// hasConventionShape tracks whether we've found a Convention-
// SPECIFIC marker — the structured convention field, or one of
// trigger / surfaces / scope / commands / direct enforcement.
// `category` alone is NOT a Convention marker (Ideas, Bugs, Roadmap
// items also use category). Used to gate the priority→enforcement
// legacy fallback below; without this gate every Task/Idea with a
// `priority` field got a phantom `convention.enforcement` surfaced
// on its response (BUG-987 bug 13).
hasConventionShape := false
if raw, ok := fieldsMap[ItemFieldConvention]; ok {
payload, err := json.Marshal(raw)
if err == nil {
var structured conventionFields
if err := json.Unmarshal(payload, &structured); err == nil {
metadata = ItemConventionMetadata{
Category: structured.Category,
Trigger: structured.Trigger,
Surfaces: append([]string(nil), structured.Surfaces...),
Enforcement: structured.Enforcement,
Commands: append([]string(nil), structured.Commands...),
}
hasMetadata = true
hasConventionShape = true
}
}
}
if metadata.Category == "" {
if category, ok := fieldsMap["category"].(string); ok {
metadata.Category = category
hasMetadata = true
// Note: category alone does NOT flip hasConventionShape —
// many non-Convention collections legitimately use it.
}
}
if metadata.Trigger == "" {
if trigger, ok := fieldsMap["trigger"].(string); ok {
metadata.Trigger = trigger
hasMetadata = true
hasConventionShape = true
}
}
// Direct enforcement only — the priority fallback runs at the
// END so surfaces/scope/commands have a chance to flip
// hasConventionShape first. Without that ordering, a legacy
// Convention like `{scope:"all", priority:"must"}` (no trigger)
// would silently drop enforcement because the fallback ran
// before scope set hasConventionShape.
if metadata.Enforcement == "" {
if value, ok := fieldsMap["enforcement"].(string); ok {
metadata.Enforcement = value
hasMetadata = true
hasConventionShape = true
}
}
if len(metadata.Surfaces) == 0 {
if surfaces := extractStringList(fieldsMap["surfaces"]); len(surfaces) > 0 {
metadata.Surfaces = surfaces
hasMetadata = true
hasConventionShape = true
} else if scope, ok := fieldsMap["scope"].(string); ok && scope != "" {
metadata.Surfaces = []string{scope}
hasMetadata = true
hasConventionShape = true
}
}
if len(metadata.Commands) == 0 {
if commands := extractStringList(fieldsMap["commands"]); len(commands) > 0 {
metadata.Commands = commands
hasMetadata = true
hasConventionShape = true
}
}
// Legacy priority→enforcement fallback. Runs AFTER all other
// markers because hasConventionShape only flips once we've seen
// a Convention-specific signal. Without this ordering, a legacy
// Convention with only `{scope, priority}` would lose its
// enforcement value because scope hadn't been processed yet
// (Codex review on PR #361 caught this).
if metadata.Enforcement == "" && hasConventionShape {
if priority, ok := fieldsMap["priority"].(string); ok {
metadata.Enforcement = priority
}
}
if !hasMetadata {
return nil
}
// Final guard: if we ONLY matched on `category` (no Convention-
// specific markers), the item isn't a Convention. Suppress the
// metadata entirely — surfacing { category } on a non-Convention
// item just for category alone produced confusing responses.
if !hasConventionShape {
return nil
}
return normalizeItemConventionMetadata(&metadata)
}
func ExtractItemImplementationNotes(fieldsJSON string) []ItemImplementationNote {
fieldsMap, ok := parseItemFields(fieldsJSON)
if !ok {
return nil
}
raw, ok := fieldsMap[ItemFieldImplementationNotes]
if !ok {
return nil
}
payload, err := json.Marshal(raw)
if err != nil {
return nil
}
var notes []ItemImplementationNote
if err := json.Unmarshal(payload, &notes); err != nil {
return nil
}
if len(notes) == 0 {
return nil
}
return notes
}
func ExtractItemDecisionLog(fieldsJSON string) []ItemDecisionLogEntry {
fieldsMap, ok := parseItemFields(fieldsJSON)
if !ok {
return nil
}
raw, ok := fieldsMap[ItemFieldDecisionLog]
if !ok {
return nil
}
payload, err := json.Marshal(raw)
if err != nil {
return nil
}
var entries []ItemDecisionLogEntry
if err := json.Unmarshal(payload, &entries); err != nil {
return nil
}
if len(entries) == 0 {
return nil
}
return entries
}
func AppendImplementationNote(fieldsJSON string, note ItemImplementationNote) (string, error) {
fieldsMap, err := parseMutableItemFields(fieldsJSON)
if err != nil {
return "", err
}
notes := ExtractItemImplementationNotes(fieldsJSON)
notes = append(notes, note)
fieldsMap[ItemFieldImplementationNotes] = notes
return marshalItemFields(fieldsMap)
}
func AppendDecisionLogEntry(fieldsJSON string, entry ItemDecisionLogEntry) (string, error) {
fieldsMap, err := parseMutableItemFields(fieldsJSON)
if err != nil {
return "", err
}
entries := ExtractItemDecisionLog(fieldsJSON)
entries = append(entries, entry)
fieldsMap[ItemFieldDecisionLog] = entries
return marshalItemFields(fieldsMap)
}
func ApplyItemConventionMetadata(fieldsJSON string, metadata *ItemConventionMetadata) (string, error) {
fieldsMap, err := parseMutableItemFields(fieldsJSON)
if err != nil {
return "", err
}
normalized := normalizeItemConventionMetadata(metadata)
if normalized == nil {
delete(fieldsMap, ItemFieldConvention)
delete(fieldsMap, "category")
delete(fieldsMap, "trigger")
delete(fieldsMap, "scope")
delete(fieldsMap, "priority")
delete(fieldsMap, "enforcement")
delete(fieldsMap, "surfaces")
delete(fieldsMap, "commands")
return marshalItemFields(fieldsMap)
}
fieldsMap[ItemFieldConvention] = normalized
fieldsMap["category"] = normalized.Category
fieldsMap["trigger"] = normalized.Trigger
fieldsMap["enforcement"] = normalized.Enforcement
fieldsMap["priority"] = normalized.Enforcement
fieldsMap["surfaces"] = normalized.Surfaces
fieldsMap["commands"] = normalized.Commands
if len(normalized.Surfaces) > 0 {
fieldsMap["scope"] = normalized.Surfaces[0]
}
return marshalItemFields(fieldsMap)
}
func BuildConventionItemFields(status string, metadata *ItemConventionMetadata) (string, error) {
fieldsJSON, err := ApplyItemConventionMetadata("{}", metadata)
if err != nil {
return "", err
}
fieldsMap, err := parseMutableItemFields(fieldsJSON)
if err != nil {
return "", err
}
if status != "" {
fieldsMap["status"] = status
}
return marshalItemFields(fieldsMap)
}
func parseItemFields(fieldsJSON string) (map[string]any, bool) {
if fieldsJSON == "" || fieldsJSON == "{}" {
return nil, false
}
var fieldsMap map[string]any
if err := json.Unmarshal([]byte(fieldsJSON), &fieldsMap); err != nil {
return nil, false
}
return fieldsMap, true
}
func parseMutableItemFields(fieldsJSON string) (map[string]any, error) {
if fieldsJSON == "" || fieldsJSON == "{}" {
return map[string]any{}, nil
}
var fieldsMap map[string]any
if err := json.Unmarshal([]byte(fieldsJSON), &fieldsMap); err != nil {
return nil, fmt.Errorf("parse item fields: %w", err)
}
return fieldsMap, nil
}
func marshalItemFields(fieldsMap map[string]any) (string, error) {
payload, err := json.Marshal(fieldsMap)
if err != nil {
return "", fmt.Errorf("marshal item fields: %w", err)
}
return string(payload), nil
}
func normalizeItemConventionMetadata(metadata *ItemConventionMetadata) *ItemConventionMetadata {
if metadata == nil {
return nil
}
normalized := &ItemConventionMetadata{
Category: metadata.Category,
Trigger: metadata.Trigger,
Enforcement: metadata.Enforcement,
Surfaces: uniqueStrings(metadata.Surfaces),
Commands: uniqueStrings(metadata.Commands),
}
if normalized.Category == "" && normalized.Trigger == "" && normalized.Enforcement == "" && len(normalized.Surfaces) == 0 && len(normalized.Commands) == 0 {
return nil
}
return normalized
}
func extractStringList(raw any) []string {
switch value := raw.(type) {
case []string:
return uniqueStrings(value)
case []any:
var out []string
for _, entry := range value {
if str, ok := entry.(string); ok && str != "" {
out = append(out, str)
}
}
return uniqueStrings(out)
default:
return nil
}
}
func uniqueStrings(values []string) []string {
if len(values) == 0 {
return nil
}
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
if len(out) == 0 {
return nil
}
return out
}
type ItemCreate struct {
Title string `json:"title"`
Content string `json:"content,omitempty"`
Fields string `json:"fields,omitempty"`
Tags string `json:"tags,omitempty"`
Pinned bool `json:"pinned,omitempty"`
ParentID *string `json:"parent_id,omitempty"`
AssignedUserID *string `json:"assigned_user_id,omitempty"`
AgentRoleID *string `json:"agent_role_id,omitempty"`
CreatedBy string `json:"created_by,omitempty"`
Source string `json:"source,omitempty"`
}
type ItemUpdate struct {
Title *string `json:"title,omitempty"`
Content *string `json:"content,omitempty"`
Fields *string `json:"fields,omitempty"`
Tags *string `json:"tags,omitempty"`
Pinned *bool `json:"pinned,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
ParentID *string `json:"parent_id,omitempty"`
AssignedUserID *string `json:"assigned_user_id,omitempty"`
AgentRoleID *string `json:"agent_role_id,omitempty"`
LastModifiedBy string `json:"last_modified_by,omitempty"`
Source string `json:"source,omitempty"`
// VersionSource overrides the per-version-row Source attribution
// without mutating `items.source`. When unset (the common case),
// the version row inherits the same value as `items.source`
// (whatever Source ends up being). When set, the version row
// uses VersionSource and `items.source` is left alone — used by
// the collab 5s-flush PATCH handler so an auto-flush doesn't
// re-attribute a CLI/MCP-created item to "collab-snapshot" and
// silently flip it out of `WorkspaceHasAgentActivity`'s count.
// Per Codex review round 3 of TASK-1267 [P2].
VersionSource string `json:"version_source,omitempty"`
ChangeSummary string `json:"change_summary,omitempty"`
Comment *string `json:"comment,omitempty"`
// OpLogCursor is the highest item_yjs_updates.id the calling client
// has applied into its local Y.Doc (TASK-1319). Used by the
// collab-snapshot flush PATCH to advance the op-log GC watermark
// (`items.content_flushed_op_log_id`) when, and only when, the
// cursor matches the current MAX(item_yjs_updates.id) for the item.
//
// **Why a pointer.** A nil cursor means "the caller didn't claim
// to know"; the watermark stays put. *0 means "I have nothing"
// (e.g. a fresh editor whose op-log is empty); when MAX is also 0
// the watermark advances to 0 (a no-op stamp) — but practical
// flushes always have *some* op-log id, so this branch rarely
// matters.
//
// Only honoured when VersionSource == "collab-snapshot". Other
// content writes (CLI / MCP / version restore / PruneAndApply)
// already advance the watermark to MAX(op-log.id) at write time
// because they reconstruct or replace items.content wholesale.
// Per TASK-1319.
OpLogCursor *int64 `json:"op_log_cursor,omitempty"`
// ClearAssignedUser / ClearAgentRole allow explicitly setting to NULL
// (since nil pointer means "don't change" in partial updates)
ClearAssignedUser bool `json:"clear_assigned_user,omitempty"`
ClearAgentRole bool `json:"clear_agent_role,omitempty"`
}
// ErrInvalidFieldsType / ErrInvalidTagsType are returned by
// ItemUpdate.UnmarshalJSON AND ItemCreate.UnmarshalJSON when the
// inbound `fields` / `tags` value is neither a JSON-encoded string
// nor the natural object/array shape. Wire handlers surface the
// sentinel's message verbatim (without the "invalid JSON: ..." wrapper
// from decodeJSON) so callers see a clean domain-level error instead
// of leaked Go internals. See BUG-1144 (Update) and BUG-1432 (Create).
var (
ErrInvalidFieldsType = errors.New(`"fields" must be a JSON object or a JSON-encoded string`)
ErrInvalidTagsType = errors.New(`"tags" must be a JSON array or a JSON-encoded string`)
)
// UnmarshalJSON for ItemCreate mirrors the flexible-shape behaviour
// ItemUpdate gained under BUG-1144: accept `fields` / `tags` either as
// the canonical JSON-encoded string shape (matches models.Item storage)
// OR as the natural nested object/array shape any reasonable HTTP
// client would send.
//
// Pre-BUG-1432 the Create path was brittle in two specific ways agents
// hit on Pad Cloud:
//
// - `tags: ["foo","bar"]` (natural JSON-array shape) was rejected by
// the default unmarshaler because the struct field is `string`,
// yielding `"cannot unmarshal array into Go struct field
// ItemCreate.tags of type string"` — an HTTP 400 the MCP
// dispatcher then surfaced as a validation_failed envelope.
//
// - `fields: {status: "open"}` (natural nested-object shape) hit the
// same brittleness with `cannot unmarshal object into ... fields of
// type string`.
//
// The asymmetry with ItemUpdate (which already handled both shapes
// cleanly per BUG-1144) was Codex's tip in the BUG-1432 investigation
// — fixing it here closes the create/update gap and gives agents a
// uniform shape contract across both verbs.
//
// In-process callers that construct ItemCreate{} literals never hit
// this path; only the JSON decode boundary changes.
func (c *ItemCreate) UnmarshalJSON(data []byte) error {
// Use an alias to inherit every other field's default unmarshal
// behaviour, while shadowing fields/tags with json.RawMessage so we
// can inspect the raw shape. The outer fields shadow the embedded
// alias's same-named fields because they are less deeply nested.
type alias ItemCreate
aux := struct {
Fields json.RawMessage `json:"fields,omitempty"`
Tags json.RawMessage `json:"tags,omitempty"`
*alias
}{alias: (*alias)(c)}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
// flexJSONToString returns *string (nil when absent / null / empty).
// ItemCreate.Fields/Tags are plain strings, not pointers, so deref
// when present and leave at zero value otherwise.
if fieldsStr, err := flexJSONToString(aux.Fields, '{', ErrInvalidFieldsType); err != nil {
return err
} else if fieldsStr != nil {
c.Fields = *fieldsStr
}
if tagsStr, err := flexJSONToString(aux.Tags, '[', ErrInvalidTagsType); err != nil {
return err
} else if tagsStr != nil {
c.Tags = *tagsStr
}
return nil
}
// UnmarshalJSON accepts `fields` / `tags` either as the canonical
// JSON-encoded string shape (matches models.Item.Fields/Tags storage)
// OR as the natural nested object/array shape any reasonable HTTP
// client would send. The struct fields stay `*string` and the rest
// of the pipeline (validation, store writes, web/CLI consumers) is
// unchanged — we just normalize the wire input here.
//
// In-process callers that construct ItemUpdate{} literals never hit
// this path, so no internal call site needs to change.
//
// See BUG-1144 (input side) and BUG-991 (the symmetric response-side
// dual-emit, fixed at the MCP boundary in PR #364).
func (u *ItemUpdate) UnmarshalJSON(data []byte) error {
// Use an alias to inherit every other field's default unmarshal
// behaviour, while shadowing fields/tags with json.RawMessage so we
// can inspect the raw shape. The outer fields shadow the embedded
// alias's same-named fields because they are less deeply nested.
type alias ItemUpdate
aux := struct {
Fields json.RawMessage `json:"fields,omitempty"`
Tags json.RawMessage `json:"tags,omitempty"`
*alias
}{alias: (*alias)(u)}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
// The alias decode leaves u.Fields / u.Tags nil (the raw bytes were
// captured at the outer level). Re-populate them from the flex parser.
fieldsStr, err := flexJSONToString(aux.Fields, '{', ErrInvalidFieldsType)
if err != nil {
return err
}
u.Fields = fieldsStr
tagsStr, err := flexJSONToString(aux.Tags, '[', ErrInvalidTagsType)
if err != nil {
return err
}
u.Tags = tagsStr
return nil
}
// flexJSONToString accepts a raw JSON value and returns it as a
// canonical JSON-encoded string. Acceptable inbound shapes:
//
// - absent / empty / null → nil (caller leaves the field unchanged)
// - JSON string → passthrough (already the canonical shape)
// - JSON object or array (matching expectedStart '{' or '[') → re-marshal to string
//
// Any other shape returns errInvalid so the handler surfaces a clean
// domain-level error instead of a leaked Go unmarshal message.
func flexJSONToString(raw json.RawMessage, expectedStart byte, errInvalid error) (*string, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return nil, nil
}
switch trimmed[0] {
case '"':
// Already a JSON-encoded string — unmarshal to the underlying Go
// string so subsequent code that does json.Unmarshal([]byte(*s), ...)
// sees the inner JSON, not a re-quoted string.
var s string
if err := json.Unmarshal(trimmed, &s); err != nil {
return nil, errInvalid
}
return &s, nil
case expectedStart:
// Object or array — re-marshal to a canonical JSON string so
// the downstream string-typed pipeline can json.Unmarshal it
// back to a map/slice exactly as if the caller had stringified.
var v any
if err := json.Unmarshal(trimmed, &v); err != nil {
return nil, errInvalid
}
b, err := json.Marshal(v)
if err != nil {
return nil, errInvalid
}
s := string(b)
return &s, nil
default:
return nil, errInvalid
}
}
type ItemListParams struct {
CollectionSlug string
CollectionIDs []string // permission filter: restrict to these collection IDs (nil = no filter)
ItemIDs []string // permission filter: additionally restrict to these item IDs (for item-level grants)
Fields map[string]string // field filters: key=value
Sort string // e.g. "priority:desc,created_at:asc"
GroupBy string
Search string // FTS query
ParentID string
Tag string
AssignedUserID string // filter by assigned user
AgentRoleID string // filter by agent role (ID or slug)
ParentLinkID string // filter by parent link (item ID of the parent)
IncludeArchived bool
Limit int
Offset int
}
type ItemLink struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
SourceID string `json:"source_id"`
TargetID string `json:"target_id"`
LinkType string `json:"link_type"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
// Populated by joins
SourceTitle string `json:"source_title,omitempty"`
TargetTitle string `json:"target_title,omitempty"`
SourceSlug string `json:"source_slug,omitempty"`
TargetSlug string `json:"target_slug,omitempty"`
SourceRef string `json:"source_ref,omitempty"`
TargetRef string `json:"target_ref,omitempty"`
SourceCollectionSlug string `json:"source_collection_slug,omitempty"`
TargetCollectionSlug string `json:"target_collection_slug,omitempty"`
SourceStatus string `json:"source_status,omitempty"`
TargetStatus string `json:"target_status,omitempty"`
}
type ItemLinkCreate struct {
TargetID string `json:"target_id"`
LinkType string `json:"link_type,omitempty"`
CreatedBy string `json:"created_by,omitempty"`
}