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)
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
<Case> is task.toJSON() with no wrapper. A response marked { task: <Case>, … } wraps it. The split is not uniform; each endpoint states which it is, and the full list is in Quirks.<Case>, <Note>, <Links>, <Template> are the shared shapes in Reference shapes. Enums are in Enums.{ error: "<message>" } unless stated. Path :id is a task-<uuid>, or any unambiguous leading fragment of one — see [[wanderland-task-api-contract#ID prefix resolution]].# required.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:
task-abc is never made ambiguous by a stored task-abcdef.task- stem is optional. A bare uuid fragment (1f2cf3af) resolves as well as task-1f2cf3af; both forms are tried.% and _ in the fragment are escaped before the LIKE, so they match literally.404 (or whatever it returned before).409 — the request does not proceed, and every candidate is listed: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.
<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
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
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" } }
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> } }
: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
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 } }
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 } ]
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 }
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>" } }
/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 } }
/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
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/*
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.