Review Flow PR 4. Stacked on #7477. Recorded failures appear in a notification bell, showing each reader the failures they are allowed to see and the actions they can actually take. Scope is deliberately viewing and routing only. Resolving a failure — retry, decrypt-and-retry — is #7479, which also brings the write path for it; nothing resolution-shaped ships here, not even dark. ## What's added **A notification bell** in the editor and the processor shell. Polls `GET /api/v1/notifications` every 30 seconds, shows an unread badge, and lists open failures newest first. Each row shows the failure's title, its message with **Copy error** and **Show full message** chips, an occurrence count, and its available actions. **A notification API** (`stirling.software.proprietary.notification`), derived from failures on read rather than stored in its own table: | Route | Purpose | |---|---| | `GET /api/v1/notifications` | the caller's open failures, newest first | Read-only by design: every action the bell offers is one the client runs on its own device, so there is nothing to post back. Every id is prefixed (`failure:<uuid>`), so the bell never holds a raw failure id it could hand to a failure endpoint. **Per-reader actions.** A `FailureKind` declares each action with an audience (`OWNER`, `TEAM_REVIEWER`, `ANYONE_WHO_SEES`). The server resolves that against the reader and derives `Ownership` (`MINE` / `THEIRS` / `UNOWNED`) from the row's actor, so an admin reviewing someone else's failure is not offered a document their browser does not hold. Adding a failure kind requires no frontend change. **Server-run and client-run actions are distinguished.** `FailureActionId` carries an `Execution` facet; the registry requires a bean only for server actions, and dispatching a client action on the failure surface returns 400. The notification projection goes further: it carries only client-run offers, so the bell cannot be sent a button it would refuse to draw. **Actions in the bell:** at most two. The owner of the document gets **View file** (opens it in the editor); a team reviewer gets **View in processor** (dev builds only). Dismiss stays on the failure queue in `/processor/documents` — deciding a failure's fate belongs to the review surface, not the panel that announces it. An action id the build has not wired is skipped rather than rendered dead, so the server can ship new kinds ahead of the clients that understand them. **Attended policy runs record their document.** `POST /api/v1/policies/{id}/run` accepts an optional opaque `fileId`, recorded when the run carries exactly one primary document. This is what lets a repeat fold onto one incident instead of opening a new one per upload, lets deleting the file clear its failure, and lets the owner open the document from the row. ## Behaviour changes - **The bell re-reads as soon as a failure you caused is recorded**, rather than leaving you to wait out a poll interval for news of your own upload. Applies to a failed tool run and to a policy run reaching `FAILED`. Other people's failures still arrive on the poll, which is what it is for. - **An action the reader cannot use is not rendered.** Where the server gave a reason for withholding it, that reason appears as the row's one-line note. An action that was never offered to that reader produces no note. - **Deleting a document closes every incident about it that the deleter caused**, including a failed policy run on their own upload, so a user's own errors leave the bell with the file rather than lingering with a dead button. - **The failures list in `/processor/documents` stays behind `import.meta.env.DEV`**, and View in processor is gated to match so it cannot navigate to a section that is not mounted. Both lift when failures get their own review screen. - **One poll for all bells.** The bell is mounted in three places; the list, document lookups and read marker are shared, so mounting more than one does not multiply requests. - `ACKNOWLEDGE` is no longer offered by any kind. The id, bean and status remain so existing rows stay readable. ## Known limits - The poll does not pause when the tab is hidden. - No retention or per-team cap on `file_run_events`. ## How to test Needs a proprietary or SaaS build with login enabled. `task dev:all`, then sign in. 1. **Create a failure.** Add a password-protected PDF to the editor and choose **Skip for now** when it asks to unlock. The upload starts a policy run that fails on it. 2. **Watch the bell.** The badge should appear within a second or two, not after 30 — this is the refresh-on-failure path. Open it: a row titled "Password-protected document" with the error message and the two chips. 3. **The buttons should be View file and View in processor, nothing else.** No Dismiss and no retries: dispositions live on the review surface, resolutions in #7479. 4. **View file** closes the panel and selects that document in the editor. 5. **Dismiss from the queue instead.** Open `/processor/documents` (dev build), find the row in the failures list and dismiss it there; the bell drops it on its next read. 6. **Confirm the local-document probe.** Create a second failure, then delete that file from the editor and reload. Its incident closes with it; a row whose document is still present keeps **View file**. 7. **Confirm attribution end to end.** Sign in as a plain member, run a shared policy on your own upload so it fails. The member sees their own row in the bell. Sign in as the team leader: they see it too, but with **View in processor** instead of **View file**, because the document is not in their browser. 8. **Confirm folding.** Add the same locked PDF again and skip again. The existing row's occurrence count increases rather than a second row appearing. 9. **Confirm one poll for many bells.** Open the editor and the processor in two tabs. Each tab issues its own poll, but within a tab the several mounted bells share one — the Network tab should show one `GET /api/v1/notifications` per 30s per tab, not three. ## Migration None. No new column and no new value in any CHECK-constrained enum; `CheckConstrainedEnumsTest` fails if that changes.
Frontend
All frontend commands are run from the repository root using Task:
task frontend:dev— start Vite dev server (localhost:5173)task frontend:build— production buildtask frontend:test— run teststask frontend:test:watch— run tests in watch modetask frontend:lint— run lintingtask frontend:typecheck— run TypeScript type checkingtask frontend:check— run typecheck + lint + testtask frontend:install— install npm dependencies
For desktop app development, see the Tauri section below.
Layout
frontend/ is a workspace containing one or more apps. Today it holds the
PDF editor under frontend/editor/; new apps (the developer portal, etc.)
will sit alongside it as siblings. Shared tooling — package.json, node_modules,
.storybook/, oxlint, oxfmt — lives at frontend/ so every app installs
once and lints with the same config.
Environment Variables
The editor's environment variables live in committed .env files at
frontend/editor/:
.env— used by all builds (core, proprietary, and as the base for desktop/SaaS).env.desktop— additional vars loaded in desktop (Tauri) mode.env.saas— additional vars loaded in SaaS mode
These files contain non-secret defaults and are checked into Git, so most dev work needs no further setup.
To override values locally (API keys, machine-specific settings), create an uncommitted sibling editor/.env.local / editor/.env.desktop.local / editor/.env.saas.local. Vite automatically layers these on top of the committed files.
Docker Setup
For Docker deployments and configuration, see the Docker README.
Tauri
All desktop tasks are available via Task. From the root of the repo:
Dev
task desktop:dev
This ensures the JLink runtime and backend JAR exist (skipping if already built), then starts Tauri in dev mode.
Build
task desktop:build
This does a full clean rebuild of the backend JAR and JLink runtime, then builds the Tauri app for production.
Platform-specific dev builds are also available:
task desktop:build:dev # No bundling
task desktop:build:dev:mac # macOS .app bundle
task desktop:build:dev:windows # Windows NSIS installer
task desktop:build:dev:linux # Linux AppImage
JLink Tasks
You can also run JLink steps individually:
task desktop:jlink # Build JAR + create JLink runtime
task desktop:jlink:jar # Build backend JAR only
task desktop:jlink:runtime # Create JLink custom JRE only
task desktop:jlink:clean # Remove JLink artifacts
Clean
task desktop:clean
Removes all desktop build artifacts including JLink runtime, bundled JARs, Cargo build, and dist/build directories.