# Fusion Dashboard Storage Audit (FN-1202) > Current authority: Fusion runtime metadata lives in PostgreSQL. `.fusion/project.json` is the local identity marker; `fusion.db`, `archive.db`, SQLite inventories, FTS5 notes, and worktree-DB hydration sections below are retained only as pre-cutover migration/history records and do not describe a supported runtime fallback. See the [2026-07-14 PostgreSQL runtime cutover review](./postgres-migration-review-2026-07-14.md) for the audited authority inventory, exact authorized legacy readers, and deployment/rollback checklist. ## SQLite→PostgreSQL cutover status - During a first-boot cutover, `fn dashboard`, `fn serve`, and `fn daemon --port ` keep their known HTTP port available with a migration holding page. Open dashboard tabs poll `/api/health` and show the migration banner with live progress. - After a successful cutover, the usual dismissible data-migrated notice may appear. If the durable cutover marker remains `running` or `failed`, real-server `/api/health` reports `status: "degraded"` with migration detail and the dashboard keeps the migration banner visible. Do not delete retained legacy `.fusion/fusion.db` backups; check logs and run `fn db migrate` after fixing a failure. - Retained `fusion.db`, `archive.db`, and `fusion-central.db` files are migration inputs and operator backups only. Startup reads a source only while its matching `fusion_sqlite_migrations` key is incomplete: `project:` gates core/archive/identity work, `central:legacy-sqlite` gates central work, and `project-plugins:` independently gates plugin adoption. A completed core marker never suppresses a still-incomplete plugin bridge. - Root-directory startup resolves a core key from an explicit project ID or the PostgreSQL `central.projects` rootDir mapping before using the deterministic fallback. A migration that learns an ID from legacy central SQLite must first materialize the same canonical rootDir-to-ID mapping in PostgreSQL before recording completion, so future boots never need the retained database to rediscover identity. ## Embedded PostgreSQL startup resources ### Windows owned-cluster recovery (FN-8522) - Windows readiness uses bounded TCP probes. Runner-log reads are diagnostic open/read/close snapshots only; Fusion keeps no persistent runner-log handle, so PostgreSQL can retain its own `pg_ctl` log without a Fusion-induced sharing-violation retry. - Each Windows PostgreSQL child gets the bundled native `bin` directory prepended to its own case-insensitive `PATH`; Fusion does not mutate the dashboard process environment. - After readiness, an **owned** cluster that logs the complete `0xC0000142` backend exception plus PostgreSQL shutdown sequence gets one lifecycle-scoped restart on the same initialized data directory and port. Joiners are never restarted or stopped. A second incident, shutdown, or failed recovery is terminal and leaves the original data directory intact. - The zero-config embedded PostgreSQL lifecycle uses mmap-backed primary shared memory to avoid exhausted SysV shared-memory IDs on constrained hosts. - The supported, tested constrained-host floor is **64MB `/dev/shm`**. Both `fn serve` and boot smoke inherit this lifecycle default; an explicit later PostgreSQL `-c shared_memory_type=…` flag remains an operator override. ## Task-ID allocator authority and compatibility - `distributed_task_id_state` is the authoritative local task-ID allocator state. `nextSequence` is the active high-water mark used for local ID reservations. - `distributed_task_id_reservations` tracks reserve/commit/abort lifecycle entries. Aborted/expired reservations are burned and never reissued. Create-class writes commit the reservation in the same PostgreSQL transaction as the `tasks` row insert, then roll back the row/partial directory and move the reservation to aborted if post-insert `task.json`/`PROMPT.md` materialization or create validation fails. - `config.nextId` is retained only as a deprecated legacy compatibility field and optional one-time seed source. Fusion still reads it during reconciliation, but runtime task creation and settings writes no longer mutate it. - Startup/store-open allocator reconciliation bumps each active prefix sequence to `max(current nextSequence, max(tasks suffix)+1, max(archivedTasks suffix)+1, max(reservation sequence)+1)` so stale allocator rows self-heal before local task creation resumes. - Create-class task persistence is intentionally non-destructive: new tasks use plain `INSERT` semantics, while upserts remain update-only. If counters drift and a reserved ID still collides, the create fails and the existing PostgreSQL row / task directory stays intact. A `committed` distributed reservation is valid only with a matching durable task row/directory; failed creates burn the reservation as `aborted` instead of leaving a committed-reservation-without-task phantom. ## Durable symbol locks (FN-8305) - `project.symbol_locks` is the project-scoped, lease-based admission seam for later mission-lineage scheduling. Its composite `(project_id, symbol_key)` identity permits only one current lock row per normalized symbol in a project; ownership records task ID plus optional mission, feature, lineage, node, and agent IDs. - Lock acquire is all-or-nothing over normalized keys. Held unexpired rows owned by another task return their owner as a conflict, while expired/released rows may be reclaimed. Renewal and release are owner-scoped and release is idempotent. - The `0000_initial.sql` baseline defines the table and indexes only. The later `0025_symbol_locks.sql` migration enables and forces RLS, creates `fusion_project_isolation`, and attaches `fusion_assign_project_id` after `0006_project_ownership.sql` creates that function/policy machinery. Both fresh full-applier and upgrade paths therefore end with the same project-isolation contract. - Startup and Batch 1 self-healing expire locks when their lease elapsed or the owner task is terminal/missing. They never move a task or alter scheduler, worktree, semaphore, or verification state. Run-audit events are `symbol-lock:acquired`, `symbol-lock:acquire-conflict`, `symbol-lock:renewed`, `symbol-lock:released`, `symbol-lock:reconcile-stale`, and deduplicated `symbol-lock:reconcile-stale-no-action`; metadata uses only counts/outcomes and normalized opaque keys. - FN-8405 adds `Task.declaredSymbols` as the durable, normalized task declaration source. `## Declared Symbols` in PROMPT.md is parsed only on create/update writes: an absent key may hydrate from the prompt, while a present `undefined`, `null` (update), or `[]` clears and suppresses hydration; a non-empty explicit array wins. Store resolution (`resolveTaskSymbols` and `resolveTaskSymbolsForWorkItem({ taskId })`) reads only the durable field, and slim projections plus archive/restore retain it. Scheduler admission remains a separate FN-8306 consumer; File Scope is never treated as a symbol source. ## Soft-deleted tasks (FN-5105) - User-initiated `TaskStore.deleteTask` is a **soft delete**: the task row stays in `tasks` and `deletedAt` is set. - Active task readers (`getTask`, `listTasks`, search, dependency scans, scheduler/watcher reads, mission task aggregations) must filter with `deletedAt IS NULL`. - Archived-task flows (`archiveTask`, archived cleanup/migration) hard-delete from the active `tasks` table after copying to PostgreSQL cold storage. Legacy `archive.db` files are import-only. - ID reservation is unchanged: soft-deleted IDs remain reserved. `distributed-task-id` and `task-id-integrity` intentionally scan all task rows (including soft-deleted rows), and must not filter on `deletedAt`. - The legacy SQLite polling replica for cross-process task lifecycle observation no longer exists. PostgreSQL stores configured with an explicit durable consumer identity use the transactional outbox described in [PostgreSQL cross-process `task:deleted` observation](./solutions/architecture/postgres-cross-process-task-deleted-observation.md); stores without an identity remain observation-disabled. Observers must not recreate writer-owned delete run-audit or mailbox effects. ### Orphaned task-dir reconciliation (FN-6783) - PostgreSQL-backed `TaskStore` instances reconcile `.fusion/tasks/{ID}/task.json` compatibility artifacts against the PostgreSQL `project.tasks` table on store open and during `SelfHealingManager` Batch 1 maintenance (`reconcile-orphaned-task-dirs`). This closes the visibility gap where a heartbeat-created task could exist on disk but be absent from `getTask`/`listTasks` and the dashboard board. - The reconcile is non-destructive: when an ID already exists anywhere the create path would reserve it (active task row, soft-deleted row, archived table/archive DB, or tombstone), the scan skips the directory and never overwrites or resurrects that ID. Only a valid live `task.json` with no DB record anywhere is re-imported. - Recovered rows preserve the on-disk task metadata, including `column`, `status`, dependencies, steps, and log, after the same defensive disk normalization used by task JSON fallback reads. Malformed or unparseable `task.json` files are skipped with a warning instead of failing store open or maintenance. - Recovery is visible: each inserted orphan emits a store warning, a `task:reconcile-orphaned-task-dir` run-audit event, and a `task:created` lifecycle event so live boards can render the recovered card. - On-disk retention matters for scan safety. `deleteTask()` leaves `.fusion/tasks/{ID}/task.json` and `agent-log.jsonl` on disk for forensics while marking the row `deletedAt`; the reconcile must skip those soft-deleted IDs. `archiveTask(id)` with the default cleanup removes the task directory, but `archiveTask(id, false)` and legacy archives can leave a `task.json` behind, so archived IDs are also guarded and skipped. ### Agent log storage + soft-delete visibility (FN-5143 / FN-5911) - Agent logs are stored outside PostgreSQL. Each task appends newline-delimited JSON records to `/.fusion/tasks/{ID}/agent-log.jsonl`. - Tool arguments and successful `tool_result` detail remain opt-in through `persistAgentToolOutput`; failed `tool_error` detail always persists as bounded diagnostic signal so task Activity transcripts can reveal the underlying failure. - Agent-log JSONL rows may include optional numeric timing metadata: `timeToFirstTokenMs` on the first visible model-output row for a request, and `durationMs` on tool/request completion rows such as `tool_result` or `tool_error`. These fields are additive, non-sensitive millisecond values; legacy rows may omit them and readers must continue to treat omission as normal. - `TaskStore.deleteTask` keeps that JSONL file on disk for forensics, but all live read APIs (`getAgentLogs*`, `getAgentLogCount`) gate on task liveness and return zero entries once `deletedAt` is set. - Archived-task snapshot behavior (`taskToArchiveEntry` / `archiveTask`) embeds a capped agent-log snapshot sourced from JSONL. - Retention is independent from PostgreSQL operational-log pruning. `settings.agentLogFileRetentionDays` controls age-based pruning of JSONL entries for soft-deleted and archived tasks only. Default: `0` (disabled). - PostgreSQL operational-log pruning is controlled separately by `settings.operationalLogRetentionDays`. It prunes `activityLog`, `runAuditEvents`, `agentHeartbeats`, terminal `agentRuns` rows by `endedAt`, and `agentConfigRevisions` by `createdAt`. - Safety invariants for operational pruning: in-flight `agentRuns` (`endedAt IS NULL`) are never deleted, and the most-recent `agentConfigRevisions` row per agent is always preserved even when older than the retention window. ### Archived-column pagination (FN-7659) - The Archived board column no longer loads the full archive into memory. `ArchiveDatabase.listPage(limit, offset)` reads a bounded page ordered `archivedAt DESC, rowid DESC` via SQL `LIMIT/OFFSET`, backed by the existing `idxArchivedTasksArchivedAt` index. - `TaskStore.listArchivedTasks({ limit, offset, slim })` is a dedicated, archive-only read path (default page size 100) that maps paged entries through `archiveEntryToTask` and returns `{ tasks, total, hasMore }` in `archivedAt DESC` order. It intentionally does **not** run the `createdAt ASC` sort used by the merged `listTasks({ includeArchived: true })` path — that merged path (and its non-board consumers: github-tracking reconciler, signal routes, agent-token-usage, self-healing) is unchanged. - `GET /tasks/archived?limit=&offset=` exposes the paged read with `projectId` scoping and `limit`/`offset` validation, returning the same `{ tasks, total, hasMore }` shape. - The dashboard's `useTasks` hook loads page 1 on first Archived-column expand and fetches subsequent pages only via an explicit "Show more" click (`loadMoreArchivedTasks`); it never re-fetches the whole archive on SSE reconnect, tab-visibility recovery, or repeated expand calls. Fetched pages merge into the board `tasks` array de-duplicated by id, with active PostgreSQL rows authoritative over archive snapshots. ### Activity-log no-op `task:moved` cleanup (FN-5940) - `TaskStore` now defends the invariant that `activityLog` never records a `task:moved` row when `metadata.from === metadata.to`. - Defense is layered: the `task:moved` listener skips same-column transitions, and source emitters skip no-op `archived -> archived` / same-column polling re-emits before subscribers see them. - Existing junk rows are removed by a one-time init migration guarded by `__meta.noOpTaskMovedActivityCleanupVersion = "1"`. - The cleanup deletes only rows matching `type = 'task:moved'` where `json_extract(metadata, '$.from') = json_extract(metadata, '$.to')`; legitimate distinct-column moves are preserved. - Historical migration note: the pre-cutover SQLite cleanup did **not** run `VACUUM` automatically. `fn db --vacuum` applies only while inspecting a retained legacy database and is not part of PostgreSQL operation. ### Dashboard delete-event handling (FN-5135) - Dashboard clients treat any SSE payload with `deletedAt != null` (`task:created`, `task:updated`, `task:moved`, `task:merged`) as a delete-equivalent and remove/suppress that task locally. - SSE slim serialization (`stripTaskListHeavyFields`) must preserve `deletedAt`; dropping it can resurrect soft-deleted cards on live boards. - Client-side SWR cache hydration also filters `deletedAt` rows before normalization as defense-in-depth; REST slim `listTasks` remains server-filtered with `deletedAt IS NULL`. ### Lineage children (FN-5129) - `deleteTask` and `archiveTask` now enforce lineage integrity for `sourceParentTaskId` links. - Default behavior: if a task still has **live lineage children** (`deletedAt IS NULL` and `column != 'archived'`) that reference it as parent, deletion/archive throws `TaskHasLineageChildrenError`. - Opt-in unlink behavior: pass `removeLineageReferences: true` to `deleteTask` or `archiveTask` to clear live children (`sourceParentTaskId = NULL`, `updatedAt` bumped, `task:updated` emitted) before removing the parent. - Gate boundary: soft-deleted children and archived-column children do **not** block parent removal; only live non-archived children block. - `cleanupArchivedTasks` intentionally tolerates dangling lineage pointers in historical/archive cleanup flows; it does not run lineage rewrites. - For forensic reads, soft-deleted parents remain accessible through `readTaskFromDb(id, { includeDeleted: true })`. - Agent-facing tool layer (FN-7661): the `fn_task_archive` and `fn_task_delete` pi/CLI tools (`packages/cli/src/extension.ts`) both accept an optional `removeLineageReferences` boolean and forward it to `store.archiveTask` / `store.deleteTask`, so an agent that hits `TaskHasLineageChildrenError` can retry with `{ removeLineageReferences: true }` to clear the block — matching the recovery path the error message already advertises. ### Documents under soft-deleted tasks (FN-5140, FX-005) - Soft-deleting a task preserves its project-scoped `task_documents` and `task_document_revisions` rows; document storage is not hard-deleted as part of `TaskStore.deleteTask`. - Editable registries remain live-only: `getAllDocuments`, `getTaskDocuments`, `GET /api/documents`, and `GET /api/tasks/:id/documents` hide rows whose parent is archived or soft-deleted. Archived documents therefore do not reappear in dashboard desktop/mobile editors. - Direct named evidence reads include retained archived rows: `getTaskDocument` / `GET /api/tasks/:id/documents/:key` return the current document, and `getTaskDocumentRevisions` / `GET .../:key/revisions` return immutable history. Missing parents/keys remain `404` for current and `[]` for history; every predicate includes `project_id`. - Ordinary writes remain forbidden: `upsertTaskDocument`, `deleteTaskDocument`, comments, artifacts, task moves/updates, and agent `fn_task_document_write` tools cannot mutate an archived parent. A single PostgreSQL-only exception, `publishArchivedTaskDocumentAddition` and `POST /api/tasks/:id/documents/:key/archived-publications`, appends an operator correction. It requires an existing project-scoped task tombstone with `column=archived` and non-null `deleted_at`, the matching `archive.archived_tasks` snapshot, and an existing current document. The request supplies non-empty `appendContent`, `author`, and `reason`, plus mandatory positive `expectedRevision` and canonical `expectedContentHash`; replacement `content`, metadata, and bypass fields are rejected. Under one transaction Fusion locks the composite parent and current document, checks both FX-004 expectations, archives the exact previous current row, and writes `priorContent + "\\n\\n" + appendContent` without trimming or normalizing either content string. Creation identity and metadata remain intact, revision advances exactly once, and one concurrent publisher wins. A stale or duplicate retry returns `TASK_DOCUMENT_PRECONDITION_FAILED` with safe revision/hash details and creates no row or side effect. The transaction changes only `task_documents`, `task_document_revisions`, and one `task-document:archived-addition-published` run-audit row. Audit metadata is ids/outcomes-only (`projectId`, `key`, previous/new revision, `reasonProvided`, `outcome`) and stores neither reason nor document content. Task timestamps/state, archive snapshots, mission/slice/feature/link state, comments, artifacts, citations, task events, workflow state, and scheduler wakeups are unchanged. The HTTP publication route is available only when daemon bearer authentication is active and is never auth-exempt. `--no-auth` fails closed with `403`; malformed input is `400`, missing parent/document is `404`, non-archived or inconsistent retained state is `409`, and stale CAS is structured `409`. Server bearer middleware rejects absent/invalid credentials before the route. The API returns `201` only after the transaction commits. ### Conditional task-document writes Current `TaskDocument` responses include `contentHash`, the SHA-256 digest of the exact UTF-8 content formatted as `sha256:<64 lowercase hex>`. Whitespace and line endings are significant; Fusion does not normalize either before hashing. `TaskDocumentCreateInput`, `PUT /api/tasks/:id/documents/:key`, and the runtime document tools accept optional compare-and-swap expectations: - omitted expectations preserve legacy unconditional writes; - `expectedRevision: 0` requires the document not to exist; - a positive `expectedRevision` requires an existing equal revision; - `expectedContentHash` requires an existing document with an equal canonical hash; - when both are present, both must match. Negative/fractional revisions and non-canonical hashes are validation errors. The PostgreSQL writer locks the active `(project_id, task_id)` parent row before reading `(project_id, task_id, key)`. The comparison, exact prior-snapshot archive, and current-row replacement occur in one transaction. Thus concurrent creates or updates from the same baseline have exactly one conditional winner. A stale writer receives `TASK_DOCUMENT_PRECONDITION_FAILED` with safe identity, supplied expectations, and current revision/hash (or `null` for absence); it creates no revision, current mutation, task event, citation scan, or success response. Document content is never included in conflict details. ### Artifact registry (FN-6777) - `artifacts` is the first-class PostgreSQL metadata registry for generated or uploaded task artifacts. Rows store ID, `type` (`document`, `image`, `video`, `audio`, or `other`), title/description, MIME type, size, author identity/type, optional task linkage, metadata JSON, textual `content`, a relative `uri`, and timestamps; binary bytes stay on disk. - `TaskStore.registerArtifact()` writes task-scoped binary payloads under `/.fusion/tasks/{ID}/artifacts/` and task-less registry payloads under `/.fusion/artifacts/`, then records a relative `artifacts/` URI in PostgreSQL. If the DB insert fails after a binary write, the store removes the orphaned file before surfacing the error. - Image task attachments (`image/png`, `image/jpeg`, `image/gif`, `image/webp`) and video task attachments (`video/mp4`, `video/webm`, `video/quicktime`; 100MB cap vs 5MB for other attachments) are bridged into the artifact registry by `TaskStore.addAttachment()` as `image`/`video` rows with `metadata.source: "attachment"` and a relative `attachments/` URI. This keeps one copy of the bytes under `/.fusion/tasks/{ID}/attachments/` while making the image discoverable through artifact list APIs and the Documents/Task Artifacts galleries. Non-image attachments remain attachment-only. Deleting an attachment also deletes its bridged artifact row before removing the attachment file so `/api/artifacts/:id/media` does not point at a deleted attachment. - Inline text/document artifacts may store `content` directly in PostgreSQL and therefore have no media file. The dashboard media route streams `GET /api/artifacts/:id/media` from disk when `uri` is present, accepting task-scoped artifact URIs under `artifacts/` and bridged image-attachment URIs under `attachments/`, or returns inline `content` with the persisted MIME type when no `uri` exists. - `getArtifact(id)` returns metadata by ID, `getArtifacts(taskId)` returns active-task artifacts newest-first, and `listArtifacts(...)` is the cross-agent query path with type/author/task/search filters and pagination. List reads hide artifacts whose parent task is soft-deleted while preserving task-less artifacts. - `updateArtifact(id, { title?, description?, content? })` powers the dashboard Artifacts view's in-place doc editing (`GET`/`PATCH /api/artifacts/:id`). Content edits are only allowed on inline-content rows (no `uri`); binary-backed rows accept metadata edits only, archived-task artifacts stay read-only, and successful updates emit `artifact:updated` for live gallery refresh. - `fn_artifact_register` accepts a local file `path` (in addition to inline `content`/`dataBase64`): the tool reads the file (50 MB cap), infers the MIME type from the extension when omitted, signature-validates image payloads (PNG/JPEG/GIF/WebP magic bytes, SVG text sniff), video payloads (mp4/mov `ftyp` box, WebM EBML header), and PDF payloads (`%PDF-` prefix), and persists the bytes through `registerArtifact()`'s managed storage path so the registry row keeps a servable URI after worktrees are cleaned up. Executor-lane registrations resolve relative paths against the task worktree and default `taskId` to the executing task. Every `path` is containment-checked before stat/read: the realpath-canonicalized file (symlinks and `../` segments resolved) must remain inside the session's `baseDir` or the OS temp directory; relative paths require a configured `baseDir`, and lanes without one (dashboard chat, no-task heartbeats) accept only absolute paths under the OS temp directory. HTML mockups register as `type="document"` + `mimeType="text/html"` (via `content` or `path`) and render as live sandboxed previews in the Artifacts view. - `GET /api/artifacts/:id/media` serves HTTP byte ranges (`Accept-Ranges: bytes`, 206 + `Content-Range` for single ranges, 416 for unsatisfiable ranges) so `