Files
fusion/packages/core/src/task-delete-attribution.ts
gsxdsm ab87d0d803 fix(api): return 404 for missing tasks, and make task deletions attributable
Three related fixes, all originating from a `[api:error] Request failed`
log line showing a 500 on `GET /api/tasks/FN-8610/runtime-fallback`.

1. Missing/deleted tasks now return 404 instead of 500.
   `getTaskImpl` signalled a miss with a bare `Error`, and route catches
   only mapped errno `ENOENT` to 404 — a leftover from the file-backed
   storage era. In Postgres mode nothing sets an errno code, so every
   unknown/missing/soft-deleted/wrong-project read returned 500. Adds a
   typed `TaskNotFoundError` (message byte-identical) plus a shared
   `task-lookup-error` mapper applied across the task, session-diff,
   git/GitHub, workflow and file-workspace route registrars. The same
   bare throw existed on both archive-lifecycle delete paths, so
   `DELETE /tasks/:id` was affected too.

2. 5xx logs now carry the origin stack.
   `rethrowAsApiError` constructed a fresh `ApiError` from the message
   and discarded the original, so the `FNXC:ApiErrorDiagnostics`
   contract logged the rethrow site rather than the throw site — the
   reported log entry had no stack at all. Threads `cause` through the
   error factories and walks the chain (bounded, cycle-guarded).

3. Task deletions are attributable, and non-operator deletes notify.
   `task:deleted` audit rows recorded `agentId: "system"` for every HTTP
   delete, making an operator click indistinguishable from a script or
   an agent; the calling agent's task id was accepted by the store and
   then never persisted. Adds a `callerKind` union recorded in audit
   metadata, tags every delete call site, and stamps a self-reported
   `x-fusion-client` header from the dashboard client. When the caller
   is `agent-tool` or `api-unattributed`, a best-effort notice is sent
   to the operator mailbox; operator and engine deletes stay silent.

`x-fusion-client` is attribution, not authentication — anything can send
it. No delete-blocking, gating or permission logic is added here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:15:36 -07:00

106 lines
5.3 KiB
TypeScript

/*
FNXC:TaskDeleteAttribution 2026-07-26-14:30:
Four tasks were deleted inside one hour in a live project and the run-audit rows could not answer
"which one did the human delete?". Two concrete holes produced that: (1) `task:deleted` metadata
persisted `auditContext.sessionId` but never `auditContext.taskId`, so an AI agent's `fn_task_delete`
call recorded only the tool surface (`agentId:"pi-extension"`) and the CALLING task/agent was lost
forever; (2) the dashboard `DELETE /api/tasks/:id` handler hardcoded `agentId:"system"`, so an
operator clicking Delete in the UI and a script hitting the same endpoint produced byte-identical
rows. This module is the single closed vocabulary that fixes both: `callerKind` names the class of
actor and `callerTaskId` names the calling task.
TRUST MODEL — read before extending. `callerKind` is ATTRIBUTION, NOT AUTHENTICATION. The HTTP
variants are derived from a self-reported `x-fusion-client` request header, so all the row can
honestly claim is "the client identified itself as the dashboard UI" (`operator-ui`) versus
"nothing identified itself" (`api-unattributed`). Anything can send the header. Never gate a
permission, a delete, or any other decision on this value, and never describe it as proof of a
human. It exists so forensics has a starting hypothesis, not a verdict.
Scope note: this is observability only. No delete-blocking, gating, or permission logic is added or
implied here; whether agents should hold `fn_task_delete` at all is a separate operator policy call.
*/
/**
* FNXC:TaskDeleteAttribution 2026-07-26-14:30:
* Closed set of delete-caller classes, ordered from most to least attributable.
*
* - `operator-ui` — an HTTP client that identified itself as the dashboard UI (see trust model).
* - `operator-cli` — the interactive `fn task delete` command, which prompts a human for
* confirmation at a terminal. Added beyond the original four because that surface is a real,
* distinct human actor already tagged `agentId:"cli"`; without its own member it would have to
* masquerade as `api-unattributed` and re-blur exactly the operator-vs-automation line this
* change exists to draw.
* - `agent-tool` — an AI agent's tool call (`fn_task_delete`). Pair with `callerTaskId`.
* - `engine` — an autonomous engine lane (triage split-close, duplicate resolution, self-healing).
* - `api-unattributed` — an HTTP caller that sent no recognized client header. The deliberate
* default: unknown is recorded as unknown rather than guessed as operator.
*/
export const TASK_DELETE_CALLER_KINDS = [
"operator-ui",
"operator-cli",
"agent-tool",
"engine",
"api-unattributed",
] as const;
export type TaskDeleteCallerKind = (typeof TASK_DELETE_CALLER_KINDS)[number];
/**
* FNXC:TaskDeleteAttribution 2026-07-26-14:30:
* Shared shape for every delete audit context. Previously this object literal was retyped inline at
* ten call signatures across `store.ts` and both archive-lifecycle branches, which is how `taskId`
* could exist on the type, be passed by `fn_task_delete`, be consumed by the self-delete guard, and
* still never reach persisted metadata without anything flagging it.
*
* `taskId` is the CALLER's task (the task whose agent is asking for the delete), not the target.
* It doubles as the self-delete guard input (`auditContext.taskId === id` is refused) and, from
* this change on, is persisted as `callerTaskId`.
*/
export interface TaskDeleteAuditContext {
agentId: string;
runId: string;
sessionId?: string;
taskId?: string;
callerKind?: TaskDeleteCallerKind;
}
/**
* FNXC:TaskDeleteAttribution 2026-07-26-14:30:
* Request header a Fusion first-party HTTP client sets to identify itself. Self-reported; see the
* module trust-model note. Kept in core so the dashboard's browser client and the dashboard's
* Express route cannot drift on the spelling.
*/
export const FUSION_CLIENT_HEADER = "x-fusion-client";
/** Value the dashboard web/desktop UI sends in {@link FUSION_CLIENT_HEADER}. */
export const FUSION_DASHBOARD_UI_CLIENT = "dashboard-ui";
/**
* FNXC:TaskDeleteAttribution 2026-07-26-14:30:
* Map a raw `x-fusion-client` header to a caller kind. Only the exact recognized dashboard-UI token
* maps to `operator-ui`; absent, array-valued (duplicate header), or unrecognized values all fall
* back to `api-unattributed` so an unknown caller is never upgraded into an operator claim.
*/
export function resolveHttpDeleteCallerKind(headerValue: unknown): TaskDeleteCallerKind {
if (typeof headerValue !== "string") return "api-unattributed";
return headerValue.trim().toLowerCase() === FUSION_DASHBOARD_UI_CLIENT
? "operator-ui"
: "api-unattributed";
}
/**
* FNXC:TaskDeleteAttribution 2026-07-26-14:30:
* Persisted metadata fragment for a `task:deleted` run-audit row. Returns enum/id values only —
* never prose, never a user-agent string — per the run-audit ids/counts/outcomes-only rule.
* `callerKind` defaults to `api-unattributed` so every row from here on carries a value and an old
* row's missing field is distinguishable from a new row's unknown caller.
*/
export function buildDeleteCallerAuditFields(
auditContext: TaskDeleteAuditContext | undefined,
): { callerKind: TaskDeleteCallerKind; callerTaskId: string | null } {
return {
callerKind: auditContext?.callerKind ?? "api-unattributed",
callerTaskId: auditContext?.taskId ?? null,
};
}