Wanderland

Wanderland Task-API — Request/Response Contract

Frontmatter

diataxis: reference
parent: wanderland-task-api
case: task-cdc00f8a-9a08-487b-b0d2-c767f5a70273
source: wanderland-task-api/src (routes/simple-tasks.js, sync.js, recurring-templates.js; models/SimpleTask.js)

Overview

The complete request and response shape of the detective task-api, endpoint by endpoint. This is the detail page the guide wanderland-task-api points into: the guide carries the map and the data model, this node carries every input and output body.

It is the like-for-like target for the White Rabbit rebuild on the crossing store — the record of .

Conventions

ID prefix resolution

A full id is 41 characters (task- plus a uuid4), and callers habitually quote only the leading chunk — task-1f2cf3af. Anywhere the API takes a task id, it accepts an abbreviation of one and resolves it to the full stored id before the handler runs. The resolution is a leading-substring match over stored ids:

error: "Ambiguous task id 'task-beef': matches 2 cases"
prefix: "task-beef"
match_count: 2
matches:
- id: "task-beef-one"
  label: "first"
  status: active
  case_type: investigation
  created_at: "<ISO8601>"
- id: "task-beef-two"
  label: "second"
  status: parked
  case_type: null
  created_at: "<ISO8601>"

Where it applies. Every :id path parameter on /api/tasks/* (via a router.param hook, so all :id routes get it uniformly), plus the task ids carried in request bodies: related_task_id on POST /:id/link-case and POST /:id/unlink-case, and each entry of task_ids on DELETE /bulk. A bulk delete aborts the whole batch on the first ambiguous entry rather than guessing; entries that resolve to nothing are skipped as before.

Where it does not apply. /api/sync and /api/recurring-templates take ids verbatim — sync moves full records between peers and template ids are a different namespace.

Implemented as SimpleStorage.resolveTaskId(idOrPrefix), surfaced through SimpleTaskService.resolveTaskId, and applied in routes/simple-tasks.js.

Reference shapes

<Case> — task.toJSON(). Required at create is only label. toJSON() omits pinned and classification (see Quirks).

id: "task-<uuid>"
label: "The case title"
priority: 3                       # int 1..5, default 3
status: parked                    # parked | active | completed | archived (default parked)
created_at: "<ISO8601>"
updated_at: "<ISO8601>"
version: 1                        # int, auto-incremented, min 1
notes: null                       # string | null — freeform markdown
consciousness_links: null         # <Links> | null
case_type: null                   # <enum case_type> | null
detective: null                   # string | null
urgency_level: routine            # routine | priority | urgent | critical | emergency
repository_context: null          # string | null
investigation_notes: []           # [<Note>]
case_links: []                    # [task-id] — flat string array
evidence_tags: []                 # [string]
auto_detected_keywords: []        # [string] — always empty (auto-detect disabled)
recurring_template_id: null       # "recurring-<uuid>" | null
recurrence_date: null             # "YYYY-MM-DD" | null
previous_instance_id: null        # "task-<uuid>" | null
scheduled_date: null              # "YYYY-MM-DD" | null — when to work on it
due_date: null                    # "YYYY-MM-DD" | null — when it is due
last_viewed: null                 # "<ISO8601>" | null — bumped by /touch only
current_resume: null              # JSON string {session_id,cwd,timestamp} | null

<Case summary> — task.toSummary(). Returned by GET /:id and GET /current when a case has more than 10 notes and full notes were not requested. Same as <Case> except:

investigation_notes: <omitted>
investigation_notes_count: <int>
_notes_access: "<hint string>"

<Note> — one investigation note:

note_id: "note-<hash8>"
timestamp: "<ISO8601>"
event_type: note_added            # free string (not enum-validated)
content: "markdown"
detective: "who wrote it"
edited_at: "<ISO8601>"           # only if edited
edited_by: "who edited"         # only if edited
context: "string"               # only on link-derived notes

Event types seen in code: note_added, case_created, interrupted, resumed, memory_linked, case_linked, field_updated, parked, closed, version_updated, urgency_escalated, git_commit_linked, link_added.

<Links> — the consciousness_links bag. All array keys optional; unknown keys ignored; merge is additive. Items are raw strings or objects; git_commits items are structured.

memories: []
insights: []
case_files: []
related_cases: []                 # in the bag, but REJECTED by POST /:id/consciousness-links
scratch_pads: []
web_links: []
jira_tickets: []
git_commits: []
stuffy_channel: []                # note: singular key
jenkins_builds: []
pull_requests: []
aws_resources: []
oculus_nodes: []
context: null                     # string | null — cross-applied to the merged links

<Template> — RecurringTemplate.toJSON():

id: "recurring-<uuid>"
label_pattern: "Recurring Case - {date}"   # placeholders: {date}{time}{day}{week}{month}{year}
case_type: investigation
detective: null
urgency_level: routine
priority: 3
notes_template: null
evidence_tags: []
schedule:
  type: daily                     # daily | weekly | monthly | cron
  time: "09:00"
  days_of_week: []                # [1..7], 1=Mon (weekly)
  day_of_month: null              # 1..31 (monthly)
  cron_expression: null           # required when type=cron
create_ahead_minutes: 60
auto_load_previous: true
auto_activate: false
enabled: true
created_at: "<ISO8601>"
updated_at: "<ISO8601>"
last_generated: null
next_scheduled: null

Enums

status:        [parked, active, completed, archived]              # invalid -> coerced to active
urgency_level: [routine, priority, urgent, critical, emergency]   # invalid -> THROWS (500 at create)
case_type:                                                        # invalid -> THROWS (500 at create)
  work:      [bug, story, feature, task, investigation]
  detective: [emergency-response, surveillance, analysis, report, follow-up,
              routine-patrol, evidence-review, witness-interview, case-closure]
  planning:  [idea, guide, epic]
classification: [work, idea]      # not serialized, not stored (see Quirks)

Status transitions:

parked    -> active      # activate | start
active    -> parked      # park (also removes from stack)
active    -> completed   # current/complete | close
completed -> active      # restart
"*"       -> archived    # archive

Cases (CRUD)

POST /api/tasks — create a case.

request:
  body:
    label: string                 # required (400 if missing)
    priority: 3
    notes: null
    consciousness_links: null
    case_type: null               # invalid value -> 500
    detective: null
    urgency_level: routine        # invalid value -> 500
    repository_context: null
    evidence_tags: []
    scheduled_date: null
    due_date: null
response:
  status: 201
  body: { task: <Case>, success: true, auto_detected: false }
errors:
  - { status: 400, body: { error: "Task label is required" } }
  - { status: 500, body: { error: "Invalid case_type '…'. Allowed: …" } }

GET /api/tasks — list / filter.

request:
  query:
    status: string
    priority: int
    case_type: string
    detective: string
    urgency_level: string
    is_case: "true"               # keep only case-typed rows
    scheduled_date: "YYYY-MM-DD"
    due_date: "YYYY-MM-DD"
    tag: string                   # repeatable; also evidence_tag, tags=comma,separated
    tag_mode: any                 # any | all (default any); exact whole-tag, case-insensitive
response:
  status: 200
  body: { tasks: [<Case>], count: <int>, total_count: <int> }   # count = filtered, total_count = before is_case

GET /api/tasks/:id — get one.

request:
  path: { id: task-id }
  query: { include_notes: "true" }   # or notes=full
response:
  status: 200
  body: <Case>                    # BARE. investigation_notes always present, each note
                                  # carrying its absolute `index`; investigation_notes_count
                                  # always alongside it.
  body_large: <Case>              # > 10 notes and full not asked: identical to <Case> except
                                  # investigation_notes holds only the latest 10, plus
                                  # investigation_notes_truncated:{total,returned,showing:"latest",
                                  # first_index,last_index,omitted} and _notes_help naming
                                  # GET /:id/notes?start=&end= for the older ones.
                                  # Same shaping on GET /api/tasks/current.
errors:
  - { status: 404, body: { error: "Task not found" } }

PATCH /api/tasks/:id — partial update.

request:
  path: { id: task-id }
  body: { "<any Case field>": "<value>" }   # whole body Object.assign-ed onto the task
response:
  status: 200
  body: <Case>                    # BARE
errors:
  - { status: 404, body: { error: "Task not found" } }

DELETE /api/tasks/:id — delete.

response:
  status: 200
  body: { deleted: true, task_id: "<id>", message: "Task deleted successfully" }
errors:
  - { status: 404, body: { error: "Task not found" } }

DELETE /api/tasks/bulk — bulk delete (declared before /:id).

request:
  body: { task_ids: [task-id], dry_run: false }   # task_ids required, non-empty
response:
  status: 200
  body_dry:  { message: "Dry run: Would delete N tasks", deleted_count, task_ids, dry_run: true }
  body_real: { message: "Successfully deleted N tasks", deleted_count, task_ids }   # task_ids = ids that existed
errors:
  - { status: 400, body: { error: "task_ids array is required and must not be empty" } }

POST /api/tasks/:id/restart — reopen a completed case.

request:
  body: { interruptionContext: string, start_immediately: false, reason: string }
response:
  status: 200
  body: { task: <Case>, restarted: true, reason: string, started: <bool> }   # +isCurrentTask,stackDepth,previousTask if start_immediately
errors:
  - { status: 404, body: { error: "Task not found" } }

Lifecycle and stack

Two axes: stack = the current-task pointer (start/current/complete/current/back/interrupt); status = the lifecycle enum (activate/park/close/restart/archive).

POST /api/tasks/:id/start — push to stack + activate.

request:
  body: { interruptionContext: <any> }
response:
  status: 200
  body: { task: <Case>, isCurrentTask: <bool>, stackDepth: <int>, previousTask: <{taskId,pausedAt,interruptionContext}|null>, bubbledUp: <bool>, previousContext: <any> }
errors:
  - { status: 500, body: { error: "Task <id> not found" } }   # note: 500, not 404

POST /api/tasks/current/complete — complete the current task.

response:
  status: 200
  body: { task: <Case>, completed: true, resumedTask: <Case|null>, stackDepth: <int>, interruptionContext: <any|null> }
errors:
  - { status: 400, body: { error: "No current task to complete" } }

POST /api/tasks/current/back — pop without completing.

request:
  body: { interruptionContext: <any> }
response:
  status: 200
  body: { currentTask: <Case>, pausedTask: <Case>, stackDepth: <int>, restoredContext: <any> }
errors:
  - { status: 400, body: { error: "No current task" } }   # or "No previous task to return to"

POST /api/tasks/interrupt — create a case and push it immediately.

request:
  body: { label: string, interruptionContext: <any>, priority: 1, notes: string, consciousness_links: <Links> }   # label + interruptionContext required
response:
  status: 201
  body: { task: <Case>, isCurrentTask: true, stackDepth: <int>, previousTask: <stack-entry|null>, interrupted: true }
errors:
  - { status: 400, body: { error: "Task label is required" } }   # or "Interruption context is required"

GET /api/tasks/current — read top-of-stack.

request:
  query: { include_notes: "true" }
response:
  status: 200
  body: <Case>                    # BARE; or JSON null when no current; or <Case summary>+notes_index when >10 notes

GET /api/tasks/stack — read the whole stack.

response:
  status: 200
  body: { currentTask: <Case|null>, stackDepth: <int>, stackTasks: [ { task: <Case>, pausedAt: "<ISO>", interruptionContext: <any> } ] }

GET /api/tasks/active — list active cases.

response:
  status: 200
  body: { tasks: [<Case>], count: <int> }   # status=active, ordered by priority, created_at

POST /api/tasks/:id/park — remove from stack (status -> parked).

request:
  body: { reason: string }
response:
  status: 200
  body: { task: <Case>, parked: true, removedFromStack: <bool>, wasCurrentTask: <bool>, reason: string, stackDepth: <int> }
errors:
  - { status: 500, body: { error: "Task <id> not found" } }

POST /api/tasks/:id/activate — status parked -> active (no stack change).

response:
  status: 200
  body: { task: <Case>, activated: true, previousStatus: string }

POST /api/tasks/:id/close — close with a resolution note (status -> completed).

request:
  body: { resolution: string }
response:
  status: 200
  body: { task: <Case>, closed: true, resolution: string }   # appends a "CASE CLOSED:" note
errors:
  - { status: 404, body: { error: "Task not found" } }

POST /api/tasks/:id/archive — soft-delete (status -> archived).

response:
  status: 200
  body: { task: <Case>, message: "Case archived successfully" }   # also sets pinned=false
errors:
  - { status: 404, body: { error: "Task not found" } }

POST /api/tasks/:id/pin — pin.

response: { status: 200, body: { task: <Case>, message: "Case pinned successfully" } }
errors:
  - { status: 404, body: { error: "Task not found" } }

POST /api/tasks/:id/unpin — unpin.

response: { status: 200, body: { task: <Case>, message: "Case unpinned successfully" } }

GET /api/tasks/pinned — list pinned.

response: { status: 200, body: { tasks: [<Case>], count: <int> } }   # pinned && status==active

POST /api/tasks/:id/touch — bump last_viewed.

response: { status: 200, body: { id: "<id>", last_viewed: "<ISO>" } }
errors:
  - { status: 404, body: { error: "Task not found" } }

GET /api/tasks/system/status — service + counts.

response:
  status: 200
  body: { task_counts: { active, completed, archived }, current_task: <Case|null>, total_tasks: <int> }   # total = active+completed+archived only

POST /api/tasks/system/archive-completed — bulk-archive completed.

response: { status: 200, body: { archived_count: <int> } }

Investigation notes

:id accepts the literal current on index/search/single/patch/delete — but NOT on the paginated GET /:id/notes. Notes are zero-based indexed, renumbered on delete.

POST /api/tasks/:id/investigation-note AND POST /api/tasks/:id/notes — add a note (aliases).

request:
  body: { note: string, detective: string }   # note required; detective defaults to OS username
response:
  status: 200
  body: <Case>                    # BARE
errors:
  - { status: 400, body: { error: "Investigation note is required" } }   # /notes alias: "Note content is required"

GET /api/tasks/:id/notes/index — lightweight index.

request: { path: { id: "task-id | current" } }
response:
  status: 200
  body: { case_id, total_notes, notes: [ { index, timestamp, event_type, detective, preview, content_length } ] }
errors:
  - { status: 404, body: { error: "Case not found" } }

GET /api/tasks/:id/notes/search — fuzzy search.

request:
  path: { id: "task-id | current" }
  query: { q: string, max_results: 10, context: 2 }   # q required (or query=)
response:
  status: 200
  body: { case_id, query, total_notes, matches_found, max_results, results: [ { index, score, timestamp, event_type, detective, context_preview, matched_fields: [string], path: string } ] }
errors:
  - { status: 400, body: { error: "Search query required (use ?q=search_term)" } }
  - { status: 404, body: { error: "Case not found" } }

GET /api/tasks/:id/notes/:noteIndex — single note.

request: { path: { id: "task-id | current", noteIndex: int } }
response:
  status: 200
  body: { case_id, index, total_notes, note: <Note> }
errors:
  - { status: 404, body: { error: "Case not found" } }
  - { status: 404, body: { error: "Note not found", total_notes, valid_range: "0-N" } }

GET /api/tasks/:id/notes — paginated range.

request:
  path: { id: task-id }           # does NOT accept current
  query: { start: 0, end: <start+9> }
response:
  status: 200
  body: { case_id, total_notes, start_index, end_index, notes: [<Note>], estimated_tokens }
errors:
  - { status: 400, body: { error: "Invalid start index", total_notes } }
  - { status: 400, body: { error: "Requested range too large", estimated_tokens, max_tokens: 20000, suggestion } }

PATCH /api/tasks/:id/notes/:noteIndex — edit a note.

request:
  path: { id: "task-id | current", noteIndex: int }
  body: { content: string, event_type: string }   # content required; adds edited_at/edited_by
response:
  status: 200
  body: { case_id, index, note: <Note>, message: "Note updated successfully" }
errors:
  - { status: 400, body: { error: "Note content is required" } }
  - { status: 404, body: { error: "Case not found" } }   # or Note not found + valid_range

DELETE /api/tasks/:id/notes/:noteIndex — delete a note (renumbers).

response:
  status: 200
  body: { case_id, deleted_index, deleted_note: <Note>, remaining_notes: <int>, message: "Note deleted successfully" }
errors:
  - { status: 404, body: { error: "Case not found" } }   # or Note not found

Metadata and links

POST /api/tasks/:id/consciousness-links — merge links (additive).

request:
  body: <Links>                   # any array keys + context; related_cases -> 400 (use /link-case)
response:
  status: 200
  body: <Case>                    # BARE
errors:
  - { status: 400, body: { error: "related_cases is not supported via /consciousness-links; use POST /api/tasks/:id/link-case …" } }

POST /api/tasks/:id/link-memory — link a memory.

request:
  body: { memory_content: string, memory_path: string, auto_create: <bool>, context: string }   # content OR path required
response:
  status: 200
  body: { task: <Case>, memory_linked: true, auto_created: <bool>, memory_path: string }
errors:
  - { status: 400, body: { error: "Memory content or path is required" } }
  - { status: 404, body: { error: "Task not found" } }

POST /api/tasks/:id/link-case — link to another case (bidirectional).

request:
  body: { related_task_id: task-id, relationship_type: related, reason: string }   # related_task_id required
response:
  status: 200
  body: { task: <Case>, linked_case: "<id>", relationship_type, reason, bidirectional: true }
errors:
  - { status: 400, body: { error: "Related task ID is required" } }
  - { status: 404, body: { error: "Task not found" } }

POST /api/tasks/:id/unlink-case — remove a case link.

request:
  body: { related_task_id: task-id, reason: string }   # related_task_id required
response:
  status: 200
  body: { task: <Case>, unlinked_case: "<id>", reason, bidirectional: true }
errors:
  - { status: 400, body: { error: "Related task ID is required" } }
  - { status: 404, body: { error: "Task not found" } }

POST /api/tasks/:id/evidence-tag — add a tag.

request: { body: { tag: string } }   # required
response: { status: 200, body: <Case> }   # BARE
errors:
  - { status: 400, body: { error: "Evidence tag is required" } }

GET /api/tasks/tags — tag cloud.

request: { query: { status: string, cases_only: "true", prefix: string } }
response:
  status: 200
  body: { tags: [ { tag: string, count: <int> } ], count, _help: {} }   # sorted count desc, then tag

POST /api/tasks/:id/assign-detective — assign.

request: { body: { detective: string } }   # required
response: { status: 200, body: <Case> }   # BARE
errors:
  - { status: 400, body: { error: "Detective name is required" } }

POST /api/tasks/:id/escalate — bump urgency one level.

request: { body: { reason: string } }
response: { status: 200, body: <Case> }   # BARE

POST /api/tasks/:id/repository-context — set repo context.

request: { body: { repository: string } }   # required
response: { status: 200, body: <Case> }   # BARE
errors:
  - { status: 400, body: { error: "Repository context is required" } }

PATCH /api/tasks/:id/field — allowlisted field update.

request:
  body: { field: string, value: <any> }   # both required
  allowlist: [label, case_type, repository_context, urgency_level, detective, scheduled_date, due_date, current_resume]
response:
  status: 200
  body: <Case>                    # BARE
errors:
  - { status: 400, body: { error: "Field and value are required" } }
  - { status: 400, body: { error: "Field '<f>' not allowed. Allowed fields: …" } }
  - { status: 404, body: { error: "Task not found" } }

POST /api/tasks/:id/git-commit — attach a commit.

request:
  body: { commit_sha: string, repository: string, commit_message: string, timestamp: "<ISO>", branch: string, repository_path: string, remote_url: string, notes: string }   # commit_sha required
response:
  status: 200
  body: { task: <Case>, git_commit_linked: <entry>, temporal_knowledge_graph_updated: true, message }
errors:
  - { status: 400, body: { error: "commit_sha is required" } }
  - { status: 409, body: { error: "Commit <8hex> already linked to this case", existing_commit: <object> } }
  - { status: 404, body: { error: "Task not found" } }

POST /api/tasks/:id/reload-case-channel — re-publish to the stream.

response:
  status: 200
  body: { success: true, channel, case_id, uploaded: true, stuffy_response: <object>, message }
errors:
  - { status: 404, body: { error: "Task not found" } }
  - { status: 400, body: { error: "Could not determine case channel name", hint } }

POST /api/tasks/active-case/refresh — re-sync the current-case channel.

response:
  status: 200
  body: { success: <bool>, channel: "active-case", current_case: { id, label }, message }
errors:
  - { status: 404, body: { error: "No current task", message } }

Views and stats

GET /api/tasks/search — full-text across label + notes.

request:
  query: { q: string, limit: 20, context: 100, include_completed: "true", include_archived: "false", since_days: <int> }   # q required
response:
  status: 200
  body:
    query: string
    total_matches: <int>
    results:
      - { case_id, case_label, case_description, status, case_type, urgency_level, detective, created_at, total_notes,
          matches: [ { location, field, snippet, note_index, timestamp, detective } ],
          resources: { jira_tickets, git_commits: <count>, oculus_nodes, stuffy_channels, web_links: <count>, related_cases },
          evidence_tags: [string] }
    _help: {}
errors:
  - { status: 400, body: { error: "…", example: "…" } }

GET /api/tasks/cases/search — structured case filter.

request: { query: { case_type, detective, urgency_level, repository_context } }
response: { status: 200, body: { cases: [<Case>], count, criteria: {} } }

GET /api/tasks/cases/emergency — emergency cases.

response: { status: 200, body: { cases: [<Case>], count } }

GET /api/tasks/cases/urgent — urgent | critical | emergency.

response: { status: 200, body: { cases: [<Case>], count } }

GET /api/tasks/cases/detective/:detective — cases for one detective.

response: { status: 200, body: { cases: [<Case>], count, detective: "<param>" } }

GET /api/tasks/cases/type/:type — cases of a case_type.

response: { status: 200, body: { cases: [<Case>], count, case_type: "<param>" } }

GET /api/tasks/cases/statistics — aggregate counts.

response:
  status: 200
  body: { total_cases, by_type: {}, by_urgency: {}, by_status: {}, emergency_count, urgent_count, active_cases }   # BARE stats object

GET /api/tasks/cases/export-stats — export history stats.

response: { status: 200, body: { export_stats: <object with totalCaseFiles>, message } }

GET /api/tasks/activity/today — cases touched today.

response:
  status: 200
  body:
    date: "YYYY-MM-DD"
    cases_worked_on: [ { case_id, case_label, events: [ { time, type, description } ], first_activity, last_activity } ]
    total_events: <int>
    activities: [ { time, event, case, description } ]

Export

POST /api/tasks/:id/export — export a case to the ingest directory.

request: { body: { version_reason: string } }   # read but unused
response:
  status: 200
  body: { exported: true, file_name, file_path, version, message: "Case file exported successfully" }
errors:
  - { status: 500, body: { error: "<msg>" } }   # non-case / missing task throws -> 500

POST /api/tasks/:id/export-version — export with a version bump.

request: { body: { update_reason: "Manual case file export" } }
response:
  status: 200
  body: { exported: true, versioned: true, file_name, file_path, version, update_reason, message: "Case file exported with version N" }

POST /api/tasks/cases/export-all — export every case.

response:
  status: 200
  body: { exported_all: true, total_cases, successful_exports, failed_exports, results: [], message }

Case lists (a derived Oculus node)

Checklists live outside the SQLite row, in a derived case-<id> graph node created lazily. Thin proxies over the Oculus vfence execute API. On an Oculus failure these return 502, not 500.

GET /api/tasks/:id/lists — the case's lists + progress.

response:
  status: 200
  body: { node: "case-<id>", node_exists: <bool>, lists: [ { name, slug, path: "case-<id>:lists.<slug>.todo", total, completed, items: [ { index, content, checked } ] } ] }
errors:
  - { status: 404, body: { error: "Case not found" } }
  - { status: 502, body: { error: "Oculus unavailable: <msg>" } }

POST /api/tasks/:id/lists — create / ensure a named list.

request: { body: { name: string } }   # required, non-empty
response:
  status: 201                     # 200 if it already existed
  body: { list: { name, slug, path, total, completed, items }, created: <bool>, node: "case-<id>" }
errors:
  - { status: 400, body: { error: "List name is required" } }
  - { status: 404, body: { error: "Case not found" } }
  - { status: 502, body: { error: "Oculus unavailable: <msg>" } }

POST /api/tasks/:id/lists/:name/execute — drive a list.

request:
  path: { id: task-id, name: "list name or slug" }
  body: { action: string, params: {}, context: string }   # action required; task-api only reads params.content (add seed)
  actions: [list, add, toggle, check, uncheck, remove, update, move, check_all, uncheck_all, clear_completed, reorder]
response:
  status: 200
  body: <fence result verbatim>   # e.g. { success: true, action, data? }; empty list -> { success:true, action:"list", data:[], empty:true }
errors:
  - { status: 400, body: { error: "action is required" } }
  - { status: 404, body: { error: "Case not found" } }
  - { status: "r.status || 502", body: { error: "Fence execution failed", detail: <fence json> } }

The action-param grammar is the toodles vfence API, not this repo. See @oculus:toodles-api-reference.

DELETE /api/tasks/:id/lists/:name — delete a list.

response:
  status: 200
  body: { deleted: true, status: <int> }
errors:
  - { status: 404, body: { error: "Case not found" } }
  - { status: 404, body: { deleted: false, reason: "node or group missing" } }   # or "list not found"
  - { status: 502, body: { error: "Oculus unavailable: <msg>" } }

Sync (peer-to-peer, mounted at /api/sync)

Manifest-first reconcile for offline clients. Server peer id is <hostname>-<pid>.

POST /api/sync/manifest — diff client vs server.

request:
  body:
    peer_id: "unknown"            # echoed as client_peer_id
    scope: { since, task_ids, statuses }
    tasks:                        # required to be an array (400 otherwise)
      - { id: task-id, updated_at: "<ISO>", notes_count: <int>, note_ids: [string] }
response:
  status: 200
  body:
    sync_id: "sync-<8hex>"
    peer_id: "<server host-pid>"
    timestamp: "<ISO>"
    diff:
      server_only: [ { id, label, updated_at, notes_count } ]
      client_only: [task-id]
      in_sync: [task-id]
      diverged: [ { id, server_updated_at, client_updated_at, server_extra_note_ids: [], client_extra_note_ids: [], task_newer_on: "server|client|equal" } ]
    summary: { total_compared, in_sync, server_only, client_only, diverged }
    client_peer_id: "<echoed>"
errors:
  - { status: 400, body: { error: "tasks must be an array" } }
  - { status: 500, body: { error: "Manifest comparison failed", message } }

POST /api/sync/pull — client pulls specific tasks/notes.

request:
  body: { sync_id: string, tasks: [task-id], notes: [ { task_id, note_ids: [string] } ] }
response:
  status: 200
  body:
    tasks: [<Case>]
    notes: { "<task_id>": [<Note>] }   # object keyed by task_id, NOT an array
    sync_id: "<echoed | null>"
errors:
  - { status: 500, body: { error: "Pull failed", message } }

POST /api/sync/push — client pushes tasks/notes (bypasses side effects).

request:
  body: { sync_id: string, tasks: [<Case>], notes: [ { task_id, notes: [<Note>] } ] }   # tasks upserted; notes appended, deduped by note_id
response:
  status: 200
  body: { accepted: { tasks_created: <int>, notes_appended: <int> }, rejected: [ { type: "task|notes", id, task_id, error } ], sync_id: "sync-<8hex>" }
errors:
  - { status: 500, body: { error: "Push failed", message } }

Recurring templates (mounted at /api/recurring-templates)

Every route wraps in { success: <bool>, … }. Errors are { success: false, error }.

GET /:
  response: { success: true, templates: [<Template>], count }
GET /enabled:
  response: { success: true, templates: [<Template>], count }
GET /upcoming:
  query: { hours: 24 }
  response: { success: true, upcoming: [ { template: <Template>, scheduledTime: "<ISO>", timeUntilMs, timeUntilMinutes } ], count, timeframe_hours }
GET /search:
  query: { q: string }            # required
  response: { success: true, templates: [<Template>], count, query }
  error_400: { success: false, error: 'Query parameter "q" is required' }
GET /:id:
  response: { success: true, template: <Template> }
  error_404: { success: false, error: "Template not found" }
GET /:id/instances:
  response: { success: true, instances: [<Case>], count, template_id }   # sorted by recurrence_date asc
POST /:
  body: { label_pattern: string, schedule: <schedule>, case_type, detective, urgency_level, priority, notes_template, evidence_tags, create_ahead_minutes, auto_load_previous, auto_activate, enabled }   # label_pattern + schedule required
  response_201: { success: true, template: <Template>, message: "Template created: <schedule desc>" }
  error_400: { success: false, error: "label_pattern is required" }   # or "schedule is required", or a schedule-validation message
PUT /:id:
  body: "<any of the create fields>"
  response: { success: true, template: <Template>, message: "Template updated successfully" }
  error_400: { success: false, error: "Template <id> not found" }   # note: 400, not 404, on update
DELETE /:id:
  response: { success: true, message: "Template deleted successfully" }
  error_404: { success: false, error: "Template not found" }
POST /:id/enable:
  response: { success: true, template: <Template>, message: "Template enabled" }
  error_400: { success: false, error: "<msg>" }
POST /:id/disable:
  response: { success: true, template: <Template>, message: "Template disabled" }
POST /:id/trigger:
  body: { scheduled_date: "<date>" }   # optional; defaults to now
  response: { success: true, instance: <Case>, message: "Instance created: <label>" }
  error_400: { success: false, error: "<msg>" }   # not found / disabled -> 400

Server-level

GET /health:
  body: { status: "healthy", timestamp: "<ISO>", version: "1.0.0-simple" }
GET /api:
  body: "<large static descriptor>"   # NOT authoritative — enum lists are stale; trust the model enums
404 (any unmatched route):
  status: 404
  body: { error: "Endpoint not found", available_endpoints: "/api" }
500 (uncaught):
  status: 500
  body: { error: "Internal server error", message: "<error.message>" }
body_limit: 10mb                  # oversized -> Express 413
middleware: [helmet, cors, morgan, express.json, express.urlencoded]
auth: none on the internal docker network; Caddy HTTP basic auth (wonderfullyweird) on https://i.loss.dev/api/tasks/*

Quirks the cutover must reproduce or deliberately fix

The bare-vs-wrapped task envelope is not uniform. Bare <Case>: GET /:id, GET /current, PATCH /:id, POST /:id/consciousness-links, /investigation-note, /notes, /evidence-tag, /assign-detective, /escalate, /repository-context, PATCH /:id/field. Wrapped { task, … }: POST / (create), /start, /park, /activate, /close, /pin, /unpin, /archive, /link-memory, /link-case, /unlink-case, /git-commit, current/complete, current/back (uses currentTask/pausedTask).

Missing-resource status is inconsistent. Task-not-found is 404 on most /:id reads and patches, but 500 on service-thrown paths (/start, /park, /escalate, /evidence-tag, /assign-detective) because those throw Task <id> not found without a statusCode. Recurring-template not-found is 404 on GET and DELETE, but 400 on PUT, enable, disable, and trigger. List endpoints return 502 when Oculus is unreachable.

pinned and classification are not serialized and not stored. toJSON() omits both, and there is no database column. A pinned:true value lives only on the in-memory object the pin, unpin, and archive responses return, so it does not survive a reload. The /pinned filter is effectively per-request state. A faithful port must decide whether to reproduce the phantom or make pinned a real stored field.

auto_detected and auto_detected_keywords are always empty — keyword auto-detection is disabled, so case_type must be passed explicitly.

GET /api is stale — its detective_case_features enum lists are partial. The model enums are authoritative.

An invalid case_type or urgency_level throws at construction and surfaces as 500, not 400. A cleaner port validates at the route and returns 400.