Source: https://romanzdk.github.io/verifair/index.md # Home

verifair

**Review the evidence, not the diff.** A self-hosted place where coding agents publish change reports and people judge them. How to say it: **verifier** (/ˈvɛrɪfaɪər/, like the word) or **veri-fair** (/ˈvɛrifɛər/, “very fair”). Both are fine. [![CI](https://github.com/romanzdk/verifair/actions/workflows/ci.yml/badge.svg)](https://github.com/romanzdk/verifair/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/romanzdk/verifair/blob/main/LICENSE) ![Python 3.15](https://img.shields.io/badge/python-3.15-blue.svg) ![Postgres 16+](https://img.shields.io/badge/postgres-16%2B-336791.svg) [Docs](https://romanzdk.github.io/verifair/) · [Quickstart](#quickstart) · [How it works](#how-it-works) · [Screenshots](#screenshots) · [API](api.md) · [Configuration](configuration.md) · [Deployment](deployment.md)

![Demo: an agent pushes a report, the author signs it, a reviewer comments on a sentence, the agent answers and pushes version 2, the author signs again and the reviewer approves](images/demo.gif) ## Why Agents now write most of the code. A person cannot read every diff they make, and reading a diff was never the best way to decide if a change is right. verifair is a small web service where agents push markdown change-review reports and humans read them, comment by selecting text, and give a verdict. Agents already handle code review. A human should judge the intent and the evidence: is this the right change, and does the proof hold up? verifair gives them one page per change to do that. ## Features - **One report per change.** Intent, the questions only a human can answer, design, edge cases, verification with commands, results and links, what was not verified, rollback, security impact, agent review findings, and the code link last. See [the report](#the-report). - **A human signs first.** The person who asked for the change reads the agent's report and signs it. Only then does the team get asked to review. - **Comment on any text.** Select a sentence to start a thread. Threads are blocking by default; a reviewer can mark one as a suggestion. - **Verdicts with rules.** Approve or request changes. Approve is refused while a blocking thread is open, and the signer cannot approve their own version. - **Versions.** The agent pushes a new version after feedback. Signatures and verdicts are kept per version; comments show when they were made on an older one. - **Plan first, when needed.** Review and approve a Markdown plan before implementation, then attach the change report at the same URL. Plan and implementation have separate signatures, comments and verdicts. Direct report submission still works. - **Optional execution evidence.** Attach a local recording or a labelled reconstruction to an implementation version. The read-only Execution tab adds context; people still sign and judge the implementation. See the [trace format and version-pinned capture recipes](tracing.md). - **Built for agents.** A small JSON API to push reports, read and answer comments, and long-poll for feedback. Ships with an [agent skill](#agent-skill). - **An Inbox for people.** Grouped by whose turn it is: needs your review, needs your signature, returned to you, waiting on others. - **Two modes.** Local mode for one person or a small trusted group. Shared mode for a team, with built-in sign-in (Google or any OpenID Connect provider), members, admins and per-member agent tokens. - **Small and plain.** FastAPI, Jinja and htmx, no JavaScript build step, strict CSP. State lives in Postgres, so you can run several replicas. Light and dark theme, works on a phone. - **Optional Slack messages** for drafts, ready-for-review, comments and verdicts. - **Request reviews** from any member on the review page, with optional emails when a change is ready for them (shared mode). ## How it works 1. The agent pushes a report. It is a draft and its status is `unsigned`. The team is not told to review it yet. 2. The human author reads it, has the agent fix what is wrong (a new version) and signs the version. Signing says: I read this and I stand behind it. The team now gets a "ready for review" message. 3. Reviewers select text and comment. A comment is a blocking thread by default, or a non-blocking suggestion. The agent can read and answer threads through the API. 4. A reviewer approves or requests changes. The person who signed cannot approve. 5. A new version has no signature, so it goes back to `unsigned`. Signatures and verdicts are kept per version. For work that needs plan review first, submit `workflow: "plan_first"` with a `plan` and no implementation body. The author signs the plan and another person approves it. The change then waits for implementation. Attach the report under the same ID with `expected_version` set to the latest snapshot; sign and review the implementation separately. An unchanged plan keeps its approval across implementation versions. An edited plan needs approval again, and an unresolved blocking plan thread pauses the workflow. See [plan review](reviewing.md#plan-first-reviews) and [the API](api.md). ```mermaid sequenceDiagram autonumber participant Agent participant Author participant verifair participant Reviewer Agent->>verifair: POST /api/reports (v1) Note over verifair: unsigned Author->>verifair: read and sign v1 Note over verifair: awaiting review Reviewer->>verifair: comment on text, request changes Agent->>verifair: GET /api/reports/{id}/wait verifair-->>Agent: new comment or verdict Agent->>verifair: answer threads, push v2 Author->>verifair: sign v2 Reviewer->>verifair: approve v2 Note over verifair: approved ``` ## Quickstart You need Docker with Compose. ```bash git clone https://github.com/romanzdk/verifair.git cd verifair docker compose up --build ``` Open http://localhost:8000. The agent token is `change-me`. Set `VERIFAIR_API_TOKEN` before you start to use another one. Users set a display name (kept in a cookie). The author signs a version, reviewers select text to comment, then approve or request changes. In a second terminal, push the sample report: ```bash VERIFAIR_API_TOKEN=change-me bash scripts/push-sample.sh ``` Open the Inbox, set your name, open the report and sign it. Then open the same page in a private window with another name to review it. The compose passwords and token are for local use. Change them for any shared setup. This is local mode. For a team, use shared mode. See [access.md](access.md). ## Screenshots ![A verifair review page: the Edge cases section of a report with an open blocking thread, the agent's answer and the reviewers on the side](images/review.png) | Inbox, light | Inbox, dark | | --- | --- | | ![Inbox in light theme](images/inbox-light.png) | ![Inbox in dark theme](images/inbox-dark.png) | | All reviews with status tabs | Review page, dark | | --- | --- | | ![All reviews list with Open, Awaiting review, Changes requested, Unsigned, Approved and All tabs](images/changes.png) | ![Review page in dark theme with a blocking thread](images/review-dark.png) | | Agent tokens (shared mode) | Members (shared mode) | | --- | --- | | ![Agent tokens page with active and revoked tokens](images/tokens.png) | ![Members page with roles](images/members.png) | | Execution trace | Execution trace, dark | | --- | --- | | ![Execution tab with the agent's call tree, a failed test run with its output, and the plan used for the implementation](images/execution.png) | ![Execution tab in dark theme](images/execution-dark.png) |

Inbox on a phone    Review page on a phone

## The report [report-template.md](report-template.md) is the layout agents should follow. Each section has a one-line hint shown as a quote. What the human must decide comes first, the evidence after. | Section | Why it is there | | --- | --- | | `Intent` | The problem, the ticket and what is out of scope. | | `Judge this` | The questions only a human can answer, so reviewers know where to look. | | `Design` | How it works, and one option that was rejected. | | `Edge cases` | One row per worry, with the check that proves it. | | `Verification` | What each check proves, the command, the result (✅, ⚠️ or ❌, with a reason for anything not run), a link, and whether it fails if the change is reverted. | | `Not verified / assumptions` | What was not checked or was taken on trust. | | `Rollback` | How to undo the change. | | `Security impact` | What it touches that matters for security, or `None` with the reason. | | `Agent reviews` | What the agent reviewers found, how bad it was, what was done and why. | | `Links` | Ticket, deploy, notebook, and the code last. | `Verification`, `Rollback` and `Security impact` are required by default. See [Report rules](api.md#report-rules). ## Agent skill [skills/verifair-review/SKILL.md](https://github.com/romanzdk/verifair/blob/main/skills/verifair-review/SKILL.md) teaches a coding agent to write the report in this template, push it, and wait for and answer review feedback. It never signs or gives verdicts. The agent needs `VERIFAIR_URL` and `VERIFAIR_API_TOKEN` (your own agent token in shared mode). Install it with [skills](https://github.com/vercel-labs/skills), which works for Claude Code, Codex, Cursor and many other agents: ```bash npx skills add romanzdk/verifair --skill verifair-review -g ``` That installs the newest skill. To match an older server, add its version tag, for example `romanzdk/verifair@v0.2.0`; the **Agent tokens** page and the first-run page show the command with your server's version. Without Node.js, download the skill from your server instead ([api.md](api.md#get-the-agent-skill)). ## API in short Push a report (push again with the same `id` to add a version): ```bash curl -sS --fail-with-body -X POST http://localhost:8000/api/reports \ -H "Authorization: Bearer $VERIFAIR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"id": "my-change", "project": "billing-service", "title": "Prorate mid-cycle plan changes", "risk": "high", "body": "## Intent\n...\n\n## Verification\n...\n\n## Rollback\n...\n\n## Security impact\nNone."}' # {"id": "my-change", "version": 1, "url": "http://localhost:8000/r/my-change"} ``` Wait up to 55 seconds for a verdict or a new comment, then read the open threads: ```bash curl -sS --max-time 60 -H "Authorization: Bearer $VERIFAIR_API_TOKEN" \ "http://localhost:8000/api/reports/my-change/wait?for=any&timeout=55" curl -sS -H "Authorization: Bearer $VERIFAIR_API_TOKEN" \ "http://localhost:8000/api/reports/my-change/comments?open=true" ``` Full reference: [api.md](api.md) (push, read, comments, wait, errors). ## Documentation | Page | What is in it | | --- | --- | | [api.md](api.md) | Push a report, read it, read and answer comments, wait for feedback, errors, required sections, provenance | | [reviewing.md](reviewing.md) | Inbox and personal views, signing, blocking threads | | [tracing.md](tracing.md) | Optional execution evidence, supported capture versions, local conversion, submission and privacy limits | | [access.md](access.md) | Local and shared mode, sign-in with Google and other providers, members and admins, agent tokens, report ownership, moderation, switching from local to shared | | [configuration.md](configuration.md) | All environment variables | | [deployment.md](deployment.md) | Run from source, Docker, deploying behind HTTPS, Postgres, backup and restore, upgrading an HTTP deployment | | [report-template.md](report-template.md) | The report layout, as a full example | | [CHANGELOG.md](https://github.com/romanzdk/verifair/blob/main/CHANGELOG.md) | What changed in each version | Agents can read all of it as Markdown: [llms.txt](https://romanzdk.github.io/verifair/llms.txt), or every page in one file in [llms-full.txt](https://romanzdk.github.io/verifair/llms-full.txt). ## Configuration | Variable | Default | Meaning | | --- | --- | --- | | `VERIFAIR_DATABASE_URL` | required | Postgres connection URL | | `VERIFAIR_API_TOKEN` | required in local mode | Bearer token agents use in local mode | | `VERIFAIR_BASE_URL` | `http://localhost:8000` | Public URL for links and notifications | | `VERIFAIR_OIDC_ISSUER` | unset | Sign-in provider, for example `https://accounts.google.com`. Turns on shared mode | | `VERIFAIR_OIDC_CLIENT_ID` | unset | Shared mode: OAuth client ID | | `VERIFAIR_OIDC_CLIENT_SECRET` | unset | Shared mode: OAuth client secret | | `VERIFAIR_SESSION_SECRET` | unset | Shared mode: signs the session cookie, at least 32 characters, same on every replica | | `VERIFAIR_OIDC_ALLOWED_DOMAINS` | unset | Shared mode: email domains that may sign in, for example `example.com`. Required with Google | | `VERIFAIR_BOOTSTRAP_ADMIN_ID` | unset | Shared mode: email of the first admin | | `VERIFAIR_REQUIRED_SECTIONS` | `Verification,Rollback,Security impact` | H2 headings every report must have | | `VERIFAIR_DB_POOL_SIZE` | `10` | Database connections per process | | `VERIFAIR_SLACK_WEBHOOK_URL` | unset | Optional Slack webhook | | `VERIFAIR_SMTP_URL` | unset | Shared mode: optional mail server for review request emails, `smtp://` or `smtps://` | | `VERIFAIR_EMAIL_FROM` | unset | Sender address of those emails. Required with `VERIFAIR_SMTP_URL` | Details: [configuration.md](configuration.md). ## Deployment verifair needs Postgres 16 or newer, and HTTPS for any browser access outside `localhost`. For a team, run it in shared mode: verifair signs people in itself with Google or another OpenID Connect provider, so no auth proxy is needed. - [Run from source](deployment.md#run-from-source) and [Docker](deployment.md#docker) - [Deploying](deployment.md#deploying): sign-in, TLS, Postgres, replicas, probes, backup and restore - [Set up shared mode](access.md#set-up-shared-mode) and [switch from local to shared mode](access.md#switching-from-local-to-shared-mode) ## Related projects verifair reviews the change report, not the diff. - Tools that review code feed its "Agent reviews" section: CodeRabbit, Greptile, Qodo, Devin Review, Claude Code Review. - Plan tools such as Kiro and [Plannotator](https://github.com/backnotprop/plannotator) can produce the Markdown reviewed before implementation. - [rcliao/comments](https://github.com/rcliao/comments) is the closest one: markdown review with a human-only verdict for agents. verifair adds the evidence template, versions, signing, a web UI for a team, and an API for agents to read and answer comments. ## Contributing Issues and pull requests are welcome. Keep changes small and the language plain. ```bash docker compose up -d db uv sync uv run pytest uv run ruff check . && uv run ruff format --check . ``` Each test gets its own schema in that database and drops it at the end. Set `VERIFAIR_TEST_DATABASE_URL` to use another Postgres. To reset everything, including the data, run `docker compose down -v`. `tests/test_js.py` runs the browser anchor code with Node and is skipped when `node` is not installed. ## License MIT. See [LICENSE](https://github.com/romanzdk/verifair/blob/main/LICENSE). Source: https://romanzdk.github.io/verifair/reviewing.md # Reviewing in the web UI How people find work, sign versions and handle threads. For who may do what in shared mode, see [access.md](access.md). ## Personal views The sidebar has four views under **Your work**: - **Inbox** (`/`, the home page) shows what needs you, grouped by whose turn it is: - **Needs your review**: changes where your review was requested and the status is awaiting review: the author has signed this version and no reviewer has given a verdict on it yet. As soon as any reviewer gives a verdict, the change leaves this group for every requested reviewer. When a reviewer requests changes, the turn passes to the author (it shows in their **Returned to you**), and it comes back to you after the author pushes and signs a new version. High risk comes first, then the ones waiting longest. Your own changes, and changes you signed, never show here, even if you are on their reviewer list. - **Needs your signature**: your unsigned changes. In local mode it also shows unsigned changes with no owner, because anyone can sign those. - **Returned to you**: your changes where a reviewer requested changes. - **Needs implementation**: your plan-first reviews whose current plan is approved and have no implementation attached yet. Ask the agent to submit the report at the same ID. - **Waiting on others**: your signed changes that wait for a review. This group starts closed. When a new version is pushed, the change goes back to the author to sign, and then back to the reviewers. When nothing needs you, the Inbox says so. With no projects yet, it shows how to connect your first agent. - **Assigned to me** (`/changes?scope=reviewer`) lists every review someone asked you to review, including finished ones. Here you are the reviewer. - **Created by me** (`/changes?scope=author`) lists every review you own (your agents published it with your token), including unsigned drafts. Here you are the author and wait for others. - **All reviews** (`/changes`) lists everyone's reviews. Everyone sees all projects and reviews. The lists and project pages (`/p/`, under **Projects** in the sidebar) have status tabs with counts. A report is at `/r/`. The **System / Light / Dark** switch at the bottom of the sidebar sets the theme for this browser (a cookie). **System** follows your operating system. Below 1024px wide the sidebar becomes a menu that opens from the top bar. On the review page it is always a menu, so the report gets the full width. Your id is at the bottom of the sidebar, with a button to copy it. Give it to your agent. Set `author_id` to the owner's id and `reviewers` to the requested reviewers' ids when publishing. In shared mode the agent token sets the owner, so `author_id` can be left out. Display names do not decide ownership. Review requests help people find work. The review page lists requested reviewers under **Reviewers** with "review requested" until they comment or give a verdict. Anyone with access can still read and review a report, and the normal signing and approval rules apply. ## Report and Plan Direct artifact submissions open on **Report** at `/r/`. When the selected version has an attached plan, use **Plan** (`/r/?tab=plan`) to read it, then return to **Report** to sign, comment or give a verdict. In this workflow the plan is read-only context: it has no separate comments, signing controls or independent approval. A report verdict is not separate approval of the plan. Version history shows the plan saved with each version, including at `/r//v/?tab=plan`. Replacing or removing a plan leaves older snapshots intact. History links keep Plan selected when the destination has a plan; a version without one opens Report. Execution is available separately when evidence is attached, as described below. Report/Plan links work with Tab/Enter, browser Back and JavaScript disabled. Comment deep links open Report. Attaching, replacing or removing a plan creates a new unsigned report version, even if the implementation body is unchanged. Sign that version on Report before anyone can give a verdict. Earlier signatures and verdicts remain in history and do not approve the new version. See [Optional plan](api.md#optional-plan) for submission. ## Plan-first reviews For a plan that needs approval before work starts, the agent creates a review with `workflow: "plan_first"`, one nonblank Markdown `plan`, and no implementation body. Plans can use any headings or none. There is no browser editor or upload form; the agent submits and revises documents through the API. The report keeps the same ID and URL through both stages. A workflow cannot be changed after creation; existing artifact reviews keep their behavior. 1. Open the review. **Plan** is selected while the plan needs action. Sign the displayed plan revision, then have another person review it. Use text selection, a section comment, or **Comment on plan** for a general concern. Signing rules are the same as for reports: owner only in shared mode, existing trusted cookie identities in local mode. 2. Reviewers approve the plan or request changes. Its signature and verdicts belong to the exact Markdown revision. Changed text needs a new signature and approval; unchanged text keeps its approval even when an implementation version is added. A revision number is the snapshot that introduced the text, so plan revision numbers can skip integers. Reverting to earlier text after an edit creates a new revision. 3. After effective plan approval, the status is **Awaiting implementation**. This is still open work. The agent attaches its normal report under the same ID with the latest `expected_version`. It may omit the plan to inherit it or repeat the exact approved text. It cannot change the plan and submit an implementation in the same push. 4. **Implementation** then becomes the default document. The owner signs its version and another person gives an implementation verdict. The plan's approval does not approve the implementation. Overall **Approved** requires an approved current plan and implementation. Use the document links to switch at any point. `/r/?tab=implementation` explicitly opens the implementation even while a plan needs action; before attachment it shows that implementation is pending. Plan and implementation comments and quote anchors stay separate. Requested reviewers stay on the review across both stages and can be asked to judge each document. An unresolved blocking plan thread pauses implementation attachment, updates, signing and verdicts, including when it is reopened after approval or began on an older plan revision. Resolving it or making it non-blocking restores approval for unchanged plan text without changing verdict history. A reviewer can record **Request plan changes** to withdraw their approval. Suggestions do not pause the gate, and implementation threads do not block plan approval. Direct artifact reviews retain their existing reopening behavior. Changing a plan after implementation exists preserves the body and its commit in a new snapshot. The plan must be approved again before implementation review can proceed, and that new snapshot needs a fresh implementation signature and verdict. Every body-containing snapshot has a separate implementation review, even when its body was inherited unchanged. History pages show the exact stored Markdown and the signature and verdicts associated with that document revision. Earlier threads may appear as stale concerns; roots from later revisions are excluded. Threads show their live replies, resolution and blocking state, rather than a frozen view of feedback at snapshot creation. Prior and superseded verdicts remain available. **Current review** status, when shown, describes today's gate separately from historical content. Metadata such as title and links keeps its latest value; only document Markdown is versioned. ## Execution When the selected snapshot has evidence, **Execution** appears beside **Report** (or **Implementation**) and **Plan** when present. Open `/r/?tab=execution` or a historical `/r//v/?tab=execution`. History keeps Execution selected even for versions without evidence. An explicit Execution URL without evidence says **No execution trace attached to this version** and links to the document; it never silently selects another tab. Reports without evidence otherwise keep their normal navigation and review behavior. Execution is read-only context, with a link back to the implementation's review controls. It has no signing, verdict, reviewer-management or comment forms. Presence, failure or missing coverage in a trace never changes approval automatically. To raise a concern, return to the implementation; plan concerns still belong on Plan. The existing plan-first approval gate and reopened-blocker holds remain in force. Start with the attachment version, reported commit, original plan revision when present, capture source and coverage warnings. A reported commit is the uploader's claim, not proof that those bytes ran. Each run is labelled **Imported recording (uploader-supplied)** or **Agent-written reconstruction**; neither label authenticates its source. A completed call does not prove correct behavior or a passing test. Assistant steps are not necessarily individual model API requests. Expand the indented call tree to read kind, title, status and known timing. Details show any recorded plain-text input/output, explanation, exit code and source reference. Missing data says **Not recorded**, rather than implying zero duration or success. Parent links appear only when the source explicitly provides them; separate roots and file order do not establish cross-run causality. Stable event links reveal their ancestors when opened. The report header shows the ticket and any merge or pull request from the report's links as a short label made from the URL, such as `billing-service!231`. The labels the agent sent stay in the **Links** line at the bottom. See [API](api.md) for the rules. ![Execution tab: the ticket and merge request links in the header, then the attachment version, reported commit and plan revision, two imported Claude Code runs with partial coverage warnings, and the call tree with a failed pytest run opened next to the plan used for this implementation](images/execution.png) Execution tab on a phone: the ticket and merge request links in the header, the run labels and warnings, then the collapsed plan above the call tree **Plan used for this implementation** shows the exact bound plan, not today's latest plan. On desktop it can sit beside the tree; on mobile it precedes it. A later revision is linked separately with an explanation that the evidence was attached against the older plan. A plan-only update can therefore show an older trace binding alongside a newer current plan. No plan is a valid state, and there is no automatic score claiming all plan steps were covered. The tree expands with keyboard controls, visible focus and text status labels, and remains usable without JavaScript. It supports light/dark themes and 320px layouts; long output wraps or scrolls inside its panel. For local capture, submission and privacy limits, see [tracing.md](tracing.md). Evidence has the report's existing readers; removing it from a new version does not erase older evidence. ## Request a review In shared mode, any member can ask someone to review a change. Under **Reviewers** on the review page, pick a member and press **Request**. The list shows active members who do not own the report and were not asked yet, sorted by name; two members with the same name show their ids. A requested reviewer who has no current verdict on either review target has a **Remove** button. A current plan verdict still protects the request after implementation attachment. Removing a request does not remove comments or verdicts. The person sees the change in their Inbox (**Needs your review**) once the author has signed the actionable document, and in **My reviews**. Agents add requests with `reviewers` when they push; a push never removes one. Requests are changed on the latest snapshot only. In local mode there is no picker. Agents set `reviewers` when they push, and **Remove** works as in shared mode. ### Emails With `VERIFAIR_SMTP_URL` and `VERIFAIR_EMAIL_FROM` set (see [configuration.md](configuration.md)), shared mode also sends a plain-text email: - when the author signs a version: to every requested reviewer except the signer, - when someone adds a reviewer while the current version is signed: to that person. Adding yourself sends nothing. A push makes a new unsigned version, so it sends no email; the reviewers get one when the author signs it. Emails go only to active members whose id is one plain address, such as `bob@example.com`. Each running verifair instance sends a person at most one email per report version, so removing and adding them again sends nothing new; a restart or another replica may send it again. An email that failed to send does not count, so after the mail server is back, removing and adding the person sends it. Local mode sends no email. For plan-first reviews emails identify **plan revision R** or **implementation vN**, and link to that document. Delivery is deduplicated separately for each target and revision. Signing an unchanged plan again after attachment sends no duplicate plan email. Adding someone while an approved plan awaits implementation keeps the review request without sending an implementation-ready email. Plans do not need a **Judge this** section; the excerpt is omitted when it is absent. Pushes send draft webhook messages, if configured, rather than ready-for-review email. Notification delivery failures do not undo saved review work. The subject is `Review requested: ""`. For plan-first reviews it ends with the document, for example `Review requested: "<title>" (plan revision 2)` or `Review requested: "<title>" (implementation v3)`. The text says who signed which version and who asked for the review, then the project, the risk, the report's **Judge this** section as plain text (the report template's `> The questions only a human can answer…` line and code blocks left out, links as their text, up to about 1,500 characters, then "Read the rest on the page.") and the link: ```text Alice signed v3 of "Prorate plan changes by day". You were asked to review it by Bob. Project: billing · Risk: high Judge this: Is a day the right unit for proration? Open it to comment, approve or request changes: https://verifair.example.com/r/prorate — Sent by verifair. Replies to this mail are not read. ``` Someone added after the version was signed gets `Bob asked you to review "<title>" (v3, signed by Alice).` as the first line. Local identities belong to a browser. In shared mode, sign-in supplies the same id (your email) on every device. In local mode, signing a report without an owner records the signer as the owner, and **My changes** also lists unowned reports whose `author` equals your identity. In shared mode only `author_id` counts, and a report without an owner is frozen (see [Report ownership](access.md#report-ownership)). ## Signing - A version must be signed before anyone can approve or request changes on it. Until then the status is `unsigned`. - The first signature on a version is kept. Signing again does nothing. - In local mode, anyone who has set a name can sign. The name is only a cookie, so this is a convenience, not a control. - In shared mode, only the owner can sign: `author_id` must equal the signed-in member's email. A report with no `author_id` cannot be signed: `This report has no owner. Push it again with an agent token under a new id.` - The person who signed a version cannot approve it or request changes on it. Someone else has to. - Only in shared mode, with sign-in, is signing a real control. See [access.md](access.md#set-up-shared-mode). ## Undo a signature or a verdict - **Unsign:** the person who signed the current version, or an admin, can press **Unsign** next to "Signed by". It works only while nobody has approved or requested changes on that version; after that it stays signed, so the reviewers' verdicts keep their meaning. Ask them to withdraw their verdict first, or push a new version. - **Unapprove** and **Withdraw request:** after you approve or request changes, that button turns into its undo. It removes your verdict on this version, and the status goes back as if you had not judged it. The other button stays, so you can also switch straight to the opposite verdict. - In a plan-first review, the plan's approval and signature cannot be undone once the implementation is attached, because the implementation was reviewed against that plan. Push a new plan revision instead. - Withdrawn verdicts are kept in the database for the record, but no longer count or show. Agents cannot sign, unsign or give verdicts, only people in the app. ## Blocking threads - A new thread is blocking by default. A reviewer can tick "Non-blocking (suggestion)" when writing it, and switch a thread between blocking and non-blocking later. In shared mode only some people can do that; see [Moderation](access.md#moderation). - Approve is refused while any blocking thread is open. Resolve the thread or mark it non-blocking first. In shared mode the message also says who can do that: the person who started the thread, the report owner for agent threads, or an admin. - Unresolved threads carry across report versions and continue to block approval. `stale` records the version where a discussion began; it does not resolve the discussion, even when its section or quote is gone. Close obsolete concerns explicitly or mark them non-blocking. - Request changes needs a summary, or an open blocking thread of your own. - The sidebar counts open blocking threads and suggestions. - In plan-first reviews blockers apply to their document, and plan blockers also pause the overall workflow as described above. The selected document's controls and the current status update after a thread is reopened, resolved or made non-blocking. ## Conventional Comments The comment form has a row of label buttons: praise, nitpick, suggestion, issue, question, thought, todo, chore and note (see [conventionalcomments.org](https://conventionalcomments.org)). They need JavaScript. - A button puts `label: ` at the start of the comment. Another button swaps the label. Clicking the pressed button removes it. - `issue` and `todo` untick "Non-blocking (suggestion)", so the thread blocks. Every other label ticks it. You can still change the box by hand. - The label is plain text in the comment (`issue: this breaks X`). Agents read it as text. You can also type it yourself, with decorations like `suggestion (non-blocking): ...`. - A known label at the start of a comment is shown as a small badge. Other text before a colon stays plain. ## Archive and delete **Archive** a review when it is done or was only a test. It leaves the Inbox, the change lists and the sidebar, but stays readable at its link, with a banner saying who archived it and when. Find archived reviews on the **Archived** tab of a list or project, and **Restore** them from the banner. The review's owner, the people asked to review it, and admins can archive and restore it, from **Manage** in the review's sidebar. Agents can do the same through the API ([api.md](api.md#archive-a-report)). People can still comment on, sign and judge an archived review from its page, but it takes no new versions until it is restored. **Archive project**, at the top of a project page, archives every review in that project at once. Only admins see it. **Delete permanently** removes a review with every version, comment, signature, verdict and review request. It cannot be undone. Only admins can do it, and they type the review id to confirm. A deleted id can be used again by a new push. In local mode, without sign-in, anyone can archive and delete. Archive, restore, delete and project archive are logged as `access action=archive`, `restore`, `delete_report` and `archive_project`. Source: https://romanzdk.github.io/verifair/api.md # API Agents use the HTTP API to push change reports, read feedback and answer it. Every call needs `Authorization: Bearer <token>`: the shared `VERIFAIR_API_TOKEN` in local mode, or the member's own agent token (`vf_...`) in shared mode. See [access.md](access.md). The running app also serves the generated OpenAPI docs at `/docs` (in shared mode only for signed-in members). - [Push a report](#push-a-report) - [Plan-first review](#plan-first-review) - [Optional execution evidence](#optional-execution-evidence) - [Read a report](#read-a-report) - [Read and answer comments](#read-and-answer-comments) - [Wait for feedback](#wait-for-feedback) - [Errors](#errors) - [Report rules](#report-rules) ## Push a report ```bash curl -sS --fail-with-body -X POST http://localhost:8000/api/reports \ -H "Authorization: Bearer $VERIFAIR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "id": "my-change", "project": "billing-service", "title": "Prorate mid-cycle plan changes", "risk": "high", "body": "## Intent\nCharge upgrades by remaining days.\n\n## Verification\nUnit tests pass.\n\n## Rollback\nRevert the merge request.\n\n## Security impact\nNone.", "ticket_url": "https://example.com/tickets/BILL-412", "links": [{"label": "Merge request", "url": "https://gitlab.example.com/billing/billing-service/-/merge_requests/231"}], "author": "Alice", "author_id": "alice@example.com", "reviewers": ["bob@example.com"], "agent": "claude", "model": "opus", "commit": "a1b2c3d" }' ``` Response: ```json {"id": "my-change", "version": 1, "url": "http://localhost:8000/r/my-change"} ``` The commands below use the report `id` as `$ID`. In shared mode, use your own agent token instead of `$VERIFAIR_API_TOKEN`. - `id` is optional on the first push. Without it, verifair makes one from the title and returns it. - To add a new version, push again with the same `id`. Without `id`, every push makes a new report. Comments stay on the version they were made on; the API marks them `stale` when a newer version exists and `found` when the quoted text can be located in the current version, including after a section rename or move. - Every push requires `project`, `title` (1 to 200 characters) and `risk` (`low`, `medium` or `high`). The default `artifact` workflow also requires nonblank Markdown `body` on every push. Select `workflow: "plan_first"` on creation to review a plan before submitting an implementation. - `project` and `id` must match `^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$`: letters, digits, `.`, `_` and `-`, starting with a letter or digit, up to 100 characters. - `ticket_url`, `session_url` and `links[].url` must start with `http://` or `https://`. - A `links` entry whose URL points to a merge or pull request (a path with `/merge_requests/231`, `/pull/12`, `/pulls/12` or `/pull-requests/12`, optionally followed by more path, a query or a `#` fragment) also shows in the report header, next to the ticket. The header shows a short label made from the URL, such as `billing-service!231` or `verifair#12`, with your `label` as its tooltip. The project name must be plain letters, digits, `.`, `_` and `-` (up to 100 characters); other URLs stay in the Links line only. The ticket badge follows the same rule and shows `ticket` otherwise. All links stay in the **Links** line at the bottom of the page, with your labels. - Optional `author` (up to 60 characters): the human author's display name. Defaults to `agent`. Use `author_id` to link ownership to a stable identity. In shared mode `author` is ignored and the report shows the token owner's name. - Optional `author_id`: the owner's stable identity, separate from the display name in `author`. Optional `reviewers`: up to 50 stable identities to request reviews from. Each ID must be 1–256 characters and cannot be `agent`. In local mode it must match the user's identity exactly; in shared mode verifair turns `author_id` and `reviewers` into lower case first. Duplicate reviewers are stored once, and the report owner is left out of `reviewers` without an error. - On later versions, a field you leave out keeps its previous value: `ticket_url`, `links`, `agent`, `model`, `session_url`, `author` and `author_id`. Send a field to replace it. `null` clears `ticket_url`, and `[]` clears `links` (`null` is refused for `links`). `agent`, `model` and `session_url` describe one writer, so they change together: send any of them and the ones you leave out become `null`. - In local mode a supplied `author_id` transfers ownership. Unlike the fields above, `null` for `author_id` leaves the existing value unchanged. - `reviewers` only adds review requests: the ids are added to the ones the change already has. `[]`, `null` or leaving it out changes nothing. To remove a request, use **Remove** on the review page in shared mode (see [reviewing.md](reviewing.md#request-a-review)). Requests belong to the change and survive signing, verdicts and new versions. - Optional provenance: `agent` and `model` (up to 60 characters each), `session_url`, and `commit` (7 to 40 lowercase hex characters, the commit the version describes). `agent`, `model` and `session_url` belong to the report; `commit` is stored per version. - An explicitly supplied body must contain every section in `VERIFAIR_REQUIRED_SECTIONS` as a top-level H2 heading, spelled exactly (case matters). Otherwise the push is refused with 422 and nothing is saved: `{"detail": "missing sections", "sections": ["Rollback"]}`. The default is `Verification,Rollback,Security impact`; set the variable to an empty string to turn the check off. - Unknown JSON keys are rejected with 422. So is text that holds a NUL character (`\u0000`) or a lone surrogate (such as `\ud800`), because Postgres cannot store them. - In shared mode the token owner owns the report. `author_id` can be left out; if sent, it must be the token owner, or the push gets 403 `"author_id must be the token owner."`. A push to an `id` that belongs to another member's report gets 403 `"This report belongs to another member."`, also when the token owner is an admin. A push to an `id` whose report has no owner gets 403 `"This report has no owner. Push it again with an agent token under a new id."`. Every requested reviewer must be an active member, or the push gets 422 with every id that is not: `{"detail": "inactive reviewers", "reviewers": ["ghost@example.com"]}`. A push sends a "draft" message to Slack (if configured). The "ready for review" message is sent when the version is signed. Report markdown: raw HTML is shown as text, not rendered, and `javascript:` links are dropped. Use plain markdown only. ### Optional plan This section describes the default `artifact` workflow. Its Plan tab stays read-only; it has no plan comments, signatures or verdicts. `POST /api/reports` accepts one optional `plan`: Markdown contents, not a file path or URL to load. Any design or implementation plan is accepted; no template or report headings are required for it. The implementation `body` and all its required sections are still required on every push. | `plan` input | First version | Later version | | --- | --- | --- | | Omitted | No plan | Inherit the immediately preceding version's plan | | `null` | No plan | Remove the plan from the new version only | | Nonblank string | Store exact text | Replace with the exact text | | Empty or whitespace-only string, wrong JSON type, NUL or lone surrogate | 422; nothing saved | 422; no metadata or version changes | Each version stores its own plan snapshot, preserving submitted whitespace and Unicode without trimming or normalization. Replacing or removing a plan leaves earlier versions intact. The existing 1 MiB (1,048,576-byte) limit covers the entire encoded JSON request, including `body`, `plan` and all other fields together. Read UTF-8 files and encode their contents with Python before sending JSON. Set `REPORT_MD` and `PLAN_MD` to your local files, and use your configured URL and token: ```bash python3 - "$REPORT_MD" "$PLAN_MD" <<'PY' | curl -sS --fail-with-body \ -X POST "$VERIFAIR_URL/api/reports" \ -H "Authorization: Bearer $VERIFAIR_API_TOKEN" \ -H "Content-Type: application/json" --data-binary @- import json, sys from pathlib import Path print(json.dumps({ "id": "my-change", "project": "my-project", "title": "Describe the implemented change", "risk": "low", "body": Path(sys.argv[1]).read_bytes().decode("utf-8"), "plan": Path(sys.argv[2]).read_bytes().decode("utf-8"), })) PY ``` For updates, reuse `id` and resend the implementation `body` and required fields. Omit the `plan` key to inherit; use `"plan": None` in Python to send JSON `null` and remove it. Do not interpolate Markdown into a shell JSON literal. Submission uses the same JSON endpoint, with no multipart upload or web editor. Every push creates a new unsigned report version, even if only the plan changes or is removed. A human must sign the new version before review; earlier signatures and verdicts remain historical. Plan context is read-only, with no separate comments or approval. The POST response remains HTTP 201 with `{id, version, url}`. See [Report and Plan](reviewing.md#report-and-plan) for navigation and history. `scripts/push-sample.sh` pushes the report template as a draft example with the fixed id `sample-prorate-mid-cycle`, so running it again adds a new version. It sends to `http://localhost:8000` unless you set `VERIFAIR_URL`. For any URL that is not localhost it also needs `VERIFAIR_ALLOW_REMOTE=1`: ```bash VERIFAIR_API_TOKEN=change-me bash scripts/push-sample.sh VERIFAIR_API_TOKEN=change-me VERIFAIR_URL=https://verifair.example.com VERIFAIR_ALLOW_REMOTE=1 bash scripts/push-sample.sh ``` ## Plan-first review Create a review with `workflow: "plan_first"`, one nonblank Markdown `plan`, and no implementation body. A plan can be any design or implementation document, including a paragraph or H1-only document; no template, skill or required headings apply. Send file contents, not a path or a URL for the server to load. ```json { "id": "proration", "project": "billing-service", "title": "Proration", "risk": "high", "workflow": "plan_first", "plan": "# Design\nUse daily proration." } ``` The response is still HTTP 201 `{id, version, url}`. If you omit `id` on creation, save the returned ID. Reuse it on every update; implementation attaches to the same review and `/r/{id}` URL. Workflow is fixed at creation. Omit `workflow` on updates to inherit it; changing it gets 409 `"workflow cannot be changed"`. A human owner signs the exact current plan revision; a different human reviewer approves it. Agent tokens cannot sign or submit verdicts. Once metadata reports `stage: "awaiting_implementation"`, submit the implementation at that same ID, repeating the required metadata and guarding the current global snapshot: ```json { "id": "proration", "project": "billing-service", "title": "Proration", "risk": "high", "expected_version": 1, "body": "## Verification\nTests passed.\n\n## Rollback\nRevert.\n\n## Security impact\nNone." } ``` Read the current state before choosing `expected_version`; the example assumes snapshot 1 is still current. A plan-first update requires this guard and at least one explicitly supplied content field. Metadata-only pushes get 422. Stale guards get `409 {"detail":"review changed","current_version":N}`. Creation must omit the guard. | Input | Artifact workflow | Plan-first workflow | | --- | --- | --- | | Omitted `body` | 422 on every push | No implementation on creation; inherit on update | | `body: null` | 422 | Accepted on creation; 422 on update | | Supplied nonblank `body` | Required sections apply | Forbidden on creation; later requires current plan approval and required sections | | Omitted `plan` | No plan on creation; inherit on update | 422 on creation; inherit on update | | `plan: null` | No plan on creation; remove from new snapshot on update | 422; a plan cannot be removed | | Supplied nonblank `plan` | Read-only context | Reviewable document; changes require fresh human signature and approval | | Non-null `commit` with omitted body | Invalid because body is required | 422, including when an existing body would be inherited | Blank strings, NUL and lone surrogates remain 422. Both documents are stored exactly, preserving spaces, Unicode and line endings. If exact bytes matter, load the UTF-8 file with `Path(path).read_bytes().decode("utf-8")`; text-mode file reads can translate CRLF. Neither document is normalized by the server. Required implementation headings are checked only on supplied bodies. The 1,048,576-byte limit covers the whole encoded request, including both documents and metadata. Every push creates a new global snapshot, even with identical text. A plan revision is the snapshot that began the current continuous run of exactly identical plan text. For example, unchanged plan revision 1 can appear in snapshots 1 and 2; an edited plan in snapshot 3 is plan revision 3. Reverting text after an intervening edit creates another revision and never restores an older approval. Implementation revisions always use global snapshot numbers. An unchanged approved plan keeps its approval across body pushes. Supplying the same approved plan together with a body is allowed. Changing a plan and submitting a body in the same push is refused: `409 {"detail":"current plan is not approved","plan_revision":R}`. There is no owner/admin override. A plan-only update inherits an existing body and its commit, but that new implementation snapshot needs a new human signature and verdicts. A snapshot with no implementation has `commit: null`. An unresolved blocking plan thread, including an older unresolved concern, pauses effective approval and implementation submission, signing and verdicts. Reopening a blocker or making a suggestion blocking also pauses it. Resolving it or making it non-blocking restores an otherwise approved unchanged plan without another verdict. Editing the plan instead always needs fresh approval. Implementation threads affect implementation approval separately. No stored signatures or verdicts are deleted. ## Optional execution evidence Traces use normalized trace schema v1. `POST /api/reports` accepts optional `trace: TraceDocument | null`. Send normalized JSON, not a native provider export, file path or URL. Responses remain HTTP 201 `{id, version, url}`. Reports without traces keep their existing behavior. See [tracing.md](tracing.md) for the schema, version-pinned capture recipes, privacy limits and safe JSON assembly with curl. | Operation | `trace` behavior | | --- | --- | | Create artifact with body | Object attaches; omission/null means no trace. | | Create plan-first without body | Object rejected; omission/null means no trace. | | Submit implementation body in either workflow | Object attaches to the new snapshot; omission/null clears evidence for this new implementation. | | Plan-only update after implementation exists | Omission preserves the old evidence and its original binding, alongside inherited body/commit. Explicit trace, including null, is rejected. | | Replace or remove attached evidence | Resubmit the implementation body as a new version with an object or null; identical body bytes are allowed. | All updates supplying a trace object require `expected_version`, including artifact updates. Artifact clients without trace retain their existing guard behavior. Plan-first updates still always require the guard and the existing content rules. A trace-only update cannot replace a body or plan update. Implementation submission still requires the current plan's effective approval; a changed plan and body cannot be submitted together. Stale guards and plan-gate conflicts remain 409. Invalid evidence/content combinations return 422 and save no metadata, snapshot or trace. The server binds evidence to the snapshot where its implementation body was submitted, that snapshot's exact plan revision when present, and the submission's claimed commit when present. Absent `plan_revision` and `commit` keys are omitted from the binding, rather than serialized as null. Clients cannot set the binding. It does not prove that the claimed commit ran. A plan-only update retains the original binding even if the currently attached plan changes. Adding, replacing or removing evidence creates a fresh unsigned implementation snapshot. Earlier implementation signatures/verdicts still refer to their exact snapshots, including evidence. Plan signatures remain tied to exact plan revisions. Trace presence, recorded failures and coverage never change approval status automatically. Reopened plan blockers retain their existing hold behavior. The canonical normalized document is at most **524288 UTF-8 bytes (512 KiB)**. The whole encoded request remains **1048576 bytes (1 MiB)**, including body, plan, trace and metadata; a valid trace can still push that request over budget (413). There is no compressed/archive upload, remote-fetch endpoint or mutable attachment endpoint. Writes use the existing token and owner checks; members cannot add evidence to another member's report. API reads keep the existing authenticated-member scope; web reads keep local/shared-mode guards. Evidence is visible to the same readers as the report, with no separate access list or unguarded download route. Validation errors and logs must not echo trace text or native source records. Removing evidence from a new version does not erase history; accidental disclosure requires the instance operator's existing data-removal process. ## Read a report `GET /api/reports/$ID` returns the report metadata, status, versions and verdicts. It returns neither Markdown document, including in `versions`. Use the exact snapshot route below to read them. ```json { "id": "my-change", "project": "billing-service", "title": "Prorate mid-cycle plan changes", "risk": "high", "ticket_url": "https://example.com/tickets/BILL-412", "links": [{"label": "Merge request", "url": "https://gitlab.example.com/billing/billing-service/-/merge_requests/231"}], "author": "Alice", "author_id": "alice@example.com", "reviewers": ["bob@example.com"], "agent": "claude", "model": "opus", "session_url": "https://example.com/sessions/42", "signed_by": "Alice", "signed_at": "2026-10-21T09:30:00+00:00", "status": "changes_requested", "workflow": "artifact", "archived_at": null, "archived_by": null, "stage": "implementation", "implementation_status": "changes_requested", "plan_review": null, "current_version": 2, "versions": [ {"version": 1, "created_at": "2026-10-21T09:00:12+00:00", "commit": "a1b2c3d", "has_implementation": true, "plan_revision": null, "has_trace": false}, {"version": 2, "created_at": "2026-10-21T11:30:40+00:00", "commit": null, "has_implementation": true, "plan_revision": null, "has_trace": false} ], "verdicts": [ {"reviewer": "Alice", "reviewer_id": "4f0c1a", "verdict": "approve", "target": "implementation", "version": 1, "current": false, "summary": "", "created_at": "2026-10-21T10:02:00+00:00"}, {"reviewer": "Bob", "reviewer_id": "9be2d7", "verdict": "request_changes", "target": "implementation", "version": 2, "current": true, "summary": "Add the leap year case.", "created_at": "2026-10-21T12:15:09+00:00"} ] } ``` - For artifact reviews, `status` is `unsigned`, `awaiting_review`, `changes_requested` or `approved`. It is `unsigned` until someone signs `current_version`; after that it only counts verdicts on `current_version` from people other than its signer. Earlier verdicts by the signer remain in the history. - `archived_at` and `archived_by` (a display name) are set while the report is archived, otherwise `null`. See [Archive a report](#archive-a-report). - `signed_by` and `signed_at` describe the signature on `current_version`, or are `null`. `agent`, `model` and `session_url` are `null` until a push sets them, or after a push clears them with `null`. - `verdict` is `approve` or `request_changes`. `current` is true only for each reviewer's latest verdict on `current_version`. When a reviewer changes their verdict on the same version, the older one has `current: false`, like verdicts on earlier versions. For plan-first reviews, top-level `status` is the overall gate; it can also be `awaiting_implementation`. `stage` is `plan` until the current plan is effectively approved, then `awaiting_implementation` without a body, or `implementation` with a body. `implementation_status` and the legacy implementation `signed_by`/`signed_at` are null until body attachment. Raw implementation status can remain `approved` while a reopened plan makes the overall status `changes_requested`. `plan_review` is null for artifact reviews. For plan-first reviews it contains: ```json { "revision": 1, "status": "approved", "signed_by": "Alice", "signed_at": "2026-10-21T09:30:00+00:00", "open_blocking": 0, "verdicts": [ {"reviewer": "Bob", "reviewer_id": "bob@example.com", "target": "plan", "verdict": "approve", "version": 1, "current": true, "summary": "", "created_at": "2026-10-21T10:02:00+00:00"} ] } ``` Plan `current` verdict flags compare against that plan revision, independent of the global snapshot. Top-level `verdicts` remain implementation-only. Each version-list item includes `has_implementation`, `plan_revision` and `has_trace`. Metadata/list reads do not include full trace payloads; use the exact snapshot route. ### Exact snapshot readback `GET /api/reports/$ID/versions/2` returns the immutable stored documents: ```json { "version": 2, "created_at": "2026-10-21T11:30:40+00:00", "commit": "abcdef0", "body": "## Verification\nTests passed.", "plan": "# Design\nUse daily proration.", "plan_revision": 1, "trace": null } ``` `body` is null before implementation attachment; `plan` is null when absent. Missing review/version gets 404. This route needs the same bearer token as other reads. Each assembled metadata/feedback response reads one consistent database snapshot, but separate HTTP calls may observe different current versions. `trace` is null when absent, otherwise a `StoredTrace` object with exactly `binding` and `document`. `binding` always contains `version` (the attachment snapshot); `plan_revision` (its original plan revision) and `commit` (the claimed commit) are included only when present. For example, an attachment at version 1 with no plan or claimed commit has `"binding": {"version": 1}`. Absent optional binding keys are omitted, not null; `document` is the validated schema-v1 `TraceDocument`. The bound plan is the exact original revision, not the latest plan or report-level agent/model/session metadata. After a plan-only update, `binding.version` can be older than the requested snapshot. See the [valid synthetic document](tracing.md#manual-reconstruction). History preserves exact Markdown and revision-associated live feedback. It is not a frozen replay: replies, resolution and blocking state on included older threads stay live. Historical pages omit roots and verdicts from later document revisions. Metadata such as title and author still describes the latest report; it is not historically versioned or separately signed. ## Read and answer comments ```bash curl -sS --fail-with-body -H "Authorization: Bearer $VERIFAIR_API_TOKEN" \ "http://localhost:8000/api/reports/$ID/comments?open=true" ``` Response (`open=true` leaves out resolved threads): ```json { "current_version": 2, "target": "implementation", "revision": 2, "threads": [ { "id": 1, "version": 1, "target": "implementation", "parent_id": null, "section": "Edge cases", "quote": "first day of the cycle", "prefix": null, "suffix": null, "author": "Alice", "author_id": "4f0c1a", "author_type": "human", "body": "Which first day?", "created_at": "2026-10-21T09:14:03+00:00", "blocking": true, "resolved": false, "resolved_by": null, "stale": true, "found": true, "moved": false, "match_start": 0, "replies": [ { "id": 2, "version": 1, "target": "implementation", "parent_id": 1, "section": "Edge cases", "quote": "first day of the cycle", "author": "agent", "author_id": "agent", "author_type": "agent", "body": "Cycle start day, inclusive.", "created_at": "2026-10-21T09:20:41+00:00", "blocking": true, "resolved": false, "resolved_by": null, "stale": true, "found": true, "prefix": null, "suffix": null, "moved": false, "match_start": 0 } ] } ] } ``` - `stale` is true when the comment belongs to an older revision of the selected target. An unchanged plan does not become stale just because implementation creates another global snapshot. - `found` is true when the quote can be located in the current version, or when the thread has no quote. Matching first tries sections with the original heading, then all sections, and tolerates whitespace changes. - `moved` is true when the quote was found in another section. `match_start` is its zero-based offset in that section’s normalized plain text, or `null` for an unlocated quote or a thread without a quote. - `prefix` and `suffix` hold optional context immediately before and after the quote, used to choose between repeated matches. - Replies carry the same anchor fields, `stale`, `found`, `moved` and `match_start` as their thread, and the thread's `resolved` and `resolved_by`. - `author_type` is `agent` or `human`. - `resolved_by` is, in shared mode, the member id of the person who resolved the thread, or `agent` when an agent token did. It is `null` for open threads, in local mode, and for threads resolved before the switch to shared mode. Post a comment with `POST /api/reports/$ID/comments` and `{body, author?, parent_id?, section?, quote?, prefix?, suffix?, blocking?, target?, revision?}`. The response is 201 with one comment (same fields as above, without `stale`, `found`, `moved`, `match_start` and `replies`). For a reply, `resolved` and `resolved_by` describe the thread after the reply was saved, so a reply that leaves the thread resolved returns `"resolved": true`. ```bash curl -sS --fail-with-body -X POST "http://localhost:8000/api/reports/$ID/comments" \ -H "Authorization: Bearer $VERIFAIR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"body": "Cycle start day, inclusive.", "parent_id": 1}' ``` - With `parent_id` it is a reply. In local mode a reply to a resolved thread opens it again. In shared mode an agent reply opens it again only when the agent started the thread; a reply to a thread a person started leaves it resolved. A reply inherits its anchor from the thread, so any non-null `section`, `quote`, `prefix` or `suffix` with `parent_id` is rejected with 422 `"replies cannot set section, quote, prefix or suffix"`. - Without `parent_id` it starts a new thread. `section` must be an H2 heading of the current version, exactly as written, for example `"section": "Edge cases"`. Text before the first `##` is the section `Intro`; a report with no `##` at all is one section called `Report`. `quote` points at a piece of text in that section, so `quote` without `section` is rejected with 422 `"quote requires section"`. - `section` and `quote` are limited to 2,000 characters each; `prefix` and `suffix` to 64 each. A quote that does not occur in the named section is rejected with 422 `"quote not found"`; it is matched like `found` above, ignoring whitespace changes. A quote repeated within the named section without nonempty context is rejected with 422 `"ambiguous quote"`. Add `prefix` or `suffix` to disambiguate it. Nothing is saved when a comment is rejected. - Without `section` the thread is a general comment on the whole report. - `blocking` defaults to `true`. Send `false` for a nit that should not hold up approval. Replies always take `blocking` from their thread. - In shared mode `author` is ignored. The comment shows the token owner's name with `'s agent`, for example `Jane's agent`. - In shared mode an agent token can only comment on its owner's reports. Other reports return 403 `"This report belongs to another member."`, and reports with no owner return 403 `"This report has no owner. Push it again with an agent token under a new id."`. ### Select feedback target Comments and wait default to `target=implementation`. For plan-first feedback explicitly request `GET /api/reports/$ID/comments?target=plan&open=true` or `/wait?target=plan`. Before body attachment, default implementation reads return no threads/events and `revision: null`. Artifact plans stay read-only: explicit plan feedback reads/writes get 422 `"artifact plans are read-only"`. Execution is read-only supporting evidence, not a feedback target; the only targets remain `implementation` and `plan`. For a plan root send `{"body":"Which rounding rule?","target":"plan","revision":1}` using the current plan revision. A root without target defaults to implementation; its revision can be omitted for legacy clients and is then chosen under the write lock. Supplying revision guards the current selected document; stale guards return `409 {"detail":"document changed","target":"plan","current_revision":R}`. Writing implementation comments before attachment gets 409 `"implementation has not been attached"`. Replies may omit target to inherit the root's target. An explicit different target gets 422. A supplied reply revision checks the **current** target revision, while the saved reply retains the older root's revision and anchor. This allows answering old concerns after a document edit; do not send the root's old revision as a guard against the new text. Root section/quote validation runs against the locked current Markdown. Comment/verdict records add `target`; their legacy `version` field means that document's revision. Comments and wait envelopes, including timeout, add `current_version`, `target` and `revision`, assembled with their threads/events in one consistent read. Compare these identities with metadata and exact readback; if they differ, reread before a guarded write. To close a thread: `POST /api/reports/$ID/comments/{comment_id}/resolve`. `comment_id` can be the first comment or any reply; the whole thread is resolved. The response is `{"resolved": true}`. Calling it again does nothing. Reviewers can reopen a resolved thread in the web UI. In shared mode an agent token can only resolve threads the agent started, on its owner's reports. On another member's report it gets 403 `"This report belongs to another member."`. On a thread a person started it gets 403 `"Agent tokens can only resolve threads the agent started on its owner's reports."`. ## Wait for feedback `GET /api/reports/$ID/wait` waits for a verdict or a new root comment on the current revision of the selected target. It checks the database every 0.5 seconds and returns one event. Replies, signatures, moderation (including resolve/reopen/blocking changes), withdrawn verdicts, unsigning and version pushes are not events, so read the report again before acting on an earlier verdict: a person may have withdrawn it. Each check follows the current selected revision, never a pinned snapshot. Feedback on older revisions is ignored, but feedback on an unchanged plan still qualifies after a body push advances the global snapshot. ```bash curl -sS --fail-with-body --max-time 60 -G \ -H "Authorization: Bearer $VERIFAIR_API_TOKEN" \ --data-urlencode "for=verdict" \ --data-urlencode "since=2026-10-21T12:15:09+00:00" \ --data-urlencode "timeout=55" \ "http://localhost:8000/api/reports/$ID/wait" ``` - `target` is `implementation` (default) or `plan`; select `plan` explicitly for plan-first feedback. - `for` is `verdict`, `comment` or `any` (the default). - `timeout` is seconds, greater than zero and at most 55; the default is 55. Fractions are accepted. - `since` is an ISO datetime. Only events strictly newer than it qualify; offsets are compared as instants and a datetime without an offset means UTC. Omitting it snapshots existing event IDs on the first database check and waits for later inserts, including inserts in the same second. Rows inserted after that snapshot remain eligible on subsequent checks. - An event returns `event`, `since`, `next_since` and either `verdict` (the same fields as a report verdict) or `comment` (the same fields as the POST comment response). The earliest qualifying timestamp wins; verdicts precede comments when timestamps tie. - In shared mode every check also checks the token again. If the token is revoked or its owner is deactivated during the wait, the call ends with 401 `"invalid token"`. - A timeout returns `{"event": "timeout", "since": "...", "next_since": "...", "current_version": 2, "target": "plan", "revision": 1}` with the same timestamp in both fields. `since` is the supplied instant normalized to UTC, or the UTC request start time when omitted. On an event, `next_since` is the event’s `created_at`. This endpoint signals feedback; it does not provide a lossless event stream. Stored event timestamps have second precision, so reusing `next_since` excludes other events from that same second. After **every result or timeout**, reread metadata and selected-target comments before interpreting effective approval or writing. This also catches non-events such as reopening. There is no lossless replay or durable resume cursor. ## Archive a report `POST /api/reports/{id}/archive` hides a finished or test report from the lists; `POST /api/reports/{id}/restore` brings it back. Both answer with the report, as `GET /api/reports/{id}` does. In shared mode a token may do this for reports its owner owns or was asked to review, otherwise 403. Archiving twice, or restoring a report that is not archived, changes nothing. An archived report stays readable by its id and URL, and people can still comment on, sign and judge it. Pushing a new version to it is refused with 409 `This report is archived. Restore it before pushing a new version.` Archiving does not end a running [wait](#wait-for-feedback): it ends with `timeout` as usual, so check `archived_at` when you read the report again after a wait. Deleting a report for good is not in the API: an admin does it in the app. A wait on a deleted report ends with 404, and the id can then be used by a new push, which starts a new report without the old history. ## Get the agent skill The usual way to install the skill is `npx skills add romanzdk/verifair@v<server version> --skill verifair-review -g` (the **Agent tokens** page shows it with the right version). Without Node.js, or on a fork that cannot reach GitHub, download it from the server: `GET /api/skill` returns the `verifair-review` skill (`SKILL.md`, `text/markdown`) that ships with this server. It needs a token like the rest of the API. This command saves it for Claude Code; other agents read the same file from their own skills folder: ```bash d=~/.claude/skills/verifair-review && mkdir -p "$d" && curl -fsS -H "Authorization: Bearer $VERIFAIR_API_TOKEN" "${VERIFAIR_URL%/}/api/skill" -o "$d/SKILL.md.new" && grep -q "^name: verifair-review" "$d/SKILL.md.new" && mv "$d/SKILL.md.new" "$d/SKILL.md" || rm -f "$d/SKILL.md.new" ``` ## Errors | Status | `detail` | | --- | --- | | 401 | `"invalid token"`. In shared mode a bearer value that does not start with `vf_` gets `"The shared token is not accepted in shared mode. Use a member agent token."` | | 403 | shared mode only: `"This report belongs to another member."`, `"This report has no owner. Push it again with an agent token under a new id."`, `"author_id must be the token owner."` or `"Agent tokens can only resolve threads the agent started on its owner's reports."` | | 404 | `"report not found"`, `"version not found"`, `"parent not found"` or `"comment not found"` | | 409 | `"id belongs to another project"`, `"workflow cannot be changed"`, `"review changed"` with `current_version`, `"document changed"` with `target`/`current_revision`, `"current plan is not approved"` with `plan_revision`, or `"implementation has not been attached"` | | 422 | a list of field errors (pydantic style), `"replies cannot set section, quote, prefix or suffix"`, `"quote requires section"`, `"quote not found"`, `"ambiguous quote"`, `"unknown section"`, `"missing sections"`, workflow/content/guard errors described above, `"artifact plans are read-only"`, `"plan revision is required"`, `"reply target conflicts with root target"`, or, in shared mode, `"inactive reviewers"` | | 413 | `"request body too large"`: the request body is over 1 MB (1,048,576 bytes). Checked before the body is parsed, for API and web routes | | 503 | not JSON: the plain text `The database is not available. Try again in a moment.` with a `Retry-After: 5` header. The database cannot be reached, or no connection was free within 10 seconds. Web pages answer the same way. If the connection drops while a write is being saved, the change may already be stored: read it back before sending it again | An unknown section also lists the valid headings: `{"detail": "unknown section", "sections": ["Intent", "Edge cases"]}`. Missing sections lists the required headings the body lacks, in the configured order. Inactive reviewers lists every requested id that is not an active member: `{"detail": "inactive reviewers", "reviewers": ["ghost@example.com"]}`. ## Report rules [report-template.md](report-template.md) is the layout agents should follow. The [README](index.md#the-report) lists its sections. ### Required sections A supplied implementation body is refused with 422 if it lacks a required top-level H2 heading. Headings follow Markdown syntax, including closing hashes (`## Verification ###`) and Setext H2 headings. Headings inside code blocks and the synthetic `Intro`/`Report` sections shown by the UI do not count. The list comes from `VERIFAIR_REQUIRED_SECTIONS`. The default is `Verification,Rollback,Security impact`. Set it to an empty string to turn the check off. ### Provenance `agent`, `model`, `session_url` and `commit` tell the reader who wrote the report and what it describes. The page shows the commit next to each snapshot. A non-null commit must accompany a supplied implementation body; plan-only updates carry any inherited body's commit. Only humans sign and give verdicts in the web UI, separately for plan and implementation. Agent tokens have no signature/verdict API. Local mode trusts the local browser's cookie identity and shared API token; it does not prove a human's identity against a directory. Use shared mode, where people sign in with your OpenID Connect provider, for enforced membership and ownership. Plan approval does not change that trust boundary. ### Upgrade boundary Restart all writers together after this schema upgrade. Existing reviews stay `artifact` and existing feedback stays implementation-targeted. Mixed old writers and plan-first data are unsupported. Preserve the database when rolling back; older code cannot safely interpret plan-only snapshots with null body. See [deployment.md](deployment.md) before a code/schema rollback. Source: https://romanzdk.github.io/verifair/report-template.md # Report template ## Intent > The problem in plain words, the ticket, and what this change does not cover. Customers who upgrade mid-month are charged the full new price today. Finance wants the charge prorated by remaining days, so an upgrade on day 20 of 30 costs one third of the difference. **Out of scope:** downgrades (stay end-of-cycle), annual plans. ## Judge this > The questions only a human can answer: product intent, trade-offs, risk you accept. - Is "first day" the day the cycle starts, or the day after the previous invoice? - Is rounding once at the end acceptable to Finance, or should each line round? ## Design > How it works in a few lines, plus one alternative you rejected and why. - New `prorate(old, new, cycle_start, cycle_end, today)` in the pricing module. Pure function, no I/O. - Called from the plan-change handler only when the new price is higher. - Rejected: storing a proration row per change. The invoice line already carries the amount, a second table would drift. ## Edge cases > One row per case a reviewer might worry about: how it is handled and which check proves it. | Case | Handled | Verified by | | --- | --- | --- | | Change on the last day of the cycle | Charged 1/30, not zero | unit test | | Change on the first day | Full difference, no proration | unit test | | Leap year February | Uses real cycle length, 29 days | unit test | | Two changes in one day | Second change prorates from the first | integration test | | Currency rounding | Round half-even to cents, once, at the end | golden file | ## Verification > Evidence, not claims: what each check proves, the command, the result (✅ passed, ⚠️ not run or partial, ❌ failed; give a reason for anything but ✅), a link to the run, and whether it fails if the change is reverted. | Proves | Command | Result | Link | Fails on revert | | --- | --- | --- | --- | --- | | Proration math for all edge cases | `pytest tests/test_pricing.py` | ✅ 212 passed | [CI job #8812](https://example.com/ci/8812) | yes | | Retried request does not prorate twice | `pytest tests/test_plan_change.py::test_retry` | ✅ passed | [CI job #8812](https://example.com/ci/8812) | yes | | 40 real customers match the Finance sheet | notebook, run all cells | ✅ 40/40 match | [notebook](https://example.com/notebooks/proration-golden) | no, it reads past invoices | | Upgrade works on staging | upgrade test account #771 in the UI | ✅ invoice line 13.33 EUR | [staging](https://example.com/staging/plan-change) | yes | | No new errors after deploy | log query, last 2h | ⚠️ 40 minutes only, staging was redeployed | [log query](https://example.com/logs?q=plan-change) | no | ## Not verified / assumptions > What you did not check and what you took on trust, so the reviewer can decide whether to trust it too. - Annual plans were not tested. We assume they never reach the plan-change handler. - Not run against production data volumes. The function is pure, so we expect no change in speed. - Assumes the billing cycle dates in the database are always in the customer's local time zone. ## Rollback > How to undo this change and what it leaves behind. Revert merge request !231 and redeploy. No schema change and no data migration. Invoices already issued with a prorated line stay as they are. ## Security impact > What the change touches that matters for security, or "None" with the reason. None. The change only reads prices and dates that the handler already loads. It adds no input from users, no new endpoint and no new permission. ## Agent reviews > What the agent reviewers found and what you did with each finding. | Finding | Severity | Disposition | Reason | | --- | --- | --- | --- | | Proration used a fixed 30-day month | high | fixed | Now uses the real cycle length | | Handler could prorate twice on a retried request | high | fixed | Now idempotent per change id | | "Add a proration table" | low | dismissed | Rejected in Design, the invoice line is the source of truth | ## Links > Ticket, deploy, notebook, then the code last. - Ticket: [BILL-412](https://example.com/tickets/BILL-412) - Staging: [plan change page](https://example.com/staging/plan-change) - Notebook: [proration golden](https://example.com/notebooks/proration-golden) - Code: [merge request !231](https://gitlab.example.com/billing/billing-service/-/merge_requests/231) Source: https://romanzdk.github.io/verifair/tracing.md # Optional execution evidence This page documents normalized trace schema v1. The capture recipes are pinned to the producer versions below; they are not observations from live model runs. Tracing is optional in both review workflows. Existing reports and submissions without traces keep working. Execution evidence gives reviewers context about recorded work. **Imported recording (uploader-supplied)** and **Agent-written reconstruction** are different kinds of evidence. Neither label authenticates a capture. A completed tool call does not prove that a feature works or a test passed, and an assistant step is not necessarily one model API request. ![Execution tab in dark theme: the call tree of an imported Claude Code recording next to the plan used for the implementation](images/execution-dark.png) Reviewers read it on the report's **Execution** tab; see [Execution](reviewing.md#execution). ## Convert a local capture `python -m verifair.trace_import` ships with the existing package. It reads only files you explicitly name and writes normalized JSON. It never starts an agent, discovers sessions, reads your agent directories automatically, uploads anything or needs provider credentials. ```sh python -m verifair.trace_import --format codex-exec-jsonl \ --source-version 0.163.0 --input capture/events.jsonl --output trace.json ``` If you do not have Python 3.15 and a verifair checkout, run the same command from the [Docker image](deployment.md#docker) (pick a tag from its table). Mount the folder with the capture; `--user` makes the output file yours, because the image runs as user `10001`: ```sh docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/work" -w /work \ ghcr.io/romanzdk/verifair:<tag> /app/.venv/bin/python -m verifair.trace_import \ --format codex-exec-jsonl --source-version 0.163.0 \ --input capture/events.jsonl --output trace.json ``` | Flag | Meaning | | --- | --- | | `--format` | One of the four format names below; native JSON is not accepted by the report API. | | `--input PATH` | Explicit local input; repeat to combine captures, except Pi takes exactly one file. | | `--output PATH` | New normalized JSON file; refuses to overwrite an existing path. Written atomically with mode `0600`. | | `--source-version VERSION` | Actual producer version you recorded; the importer does not read a version file automatically. | | `--include-details` | Include bounded, allowlisted text after best-effort credential removal. Inspect before sharing. | | `--last-events N` | Keep the final N recognized events across the combined inputs, after merging updates; `1–1000`. Marks coverage partial and removes empty runs and unavailable parent references. | | `--leaf ENTRY_ID` | Select ancestry in a branched Pi file; required when there is no unique leaf. | Limits: 16 input files, 64 MiB combined raw input and 1 MiB per JSONL record. Archives and remote URLs are unsupported. Unknown format families, incompatible major/schema versions, no recognized events or unresolved required branch ancestry fail with a concise error. Missing or untested producer versions in a recognized family produce warnings. Unknown record kinds produce coverage warnings; unknown optional fields are ignored, not copied wholesale. Missing completion records leave events running or unknown and mark coverage partial. Checkpoints and compaction remain visible; discarded history stays missing. Only documented source IDs deduplicate records. Repeated start/update/end records become one event. File order across runs is display order, not measured chronology or delegation. ## Supported capture formats These are the initial compatibility baselines, checked from source/help on 2026-10-25. They do not promise compatibility with every release. Check the producer's current help and record the actual version when capturing. The [synthetic fixture index](https://github.com/romanzdk/verifair/blob/main/tests/fixtures/traces/README.md) links each pinned capture and its provenance. Integration checks exercise all four converters through report submission and the Execution view in both workflows. These checks use synthetic records; no live model run or private transcript is a compatibility claim. | Format | Pinned baseline | Scope and source | | --- | --- | --- | | `claude-stream-json` | Claude Code 2.1.309; documented SDK stream | Prospective native capture. Join tool-use/result IDs; a content-block stop is not tool completion. Child internals require actual child records. [CLI](https://code.claude.com/docs/en/cli-reference), [stream contract](https://code.claude.com/docs/en/agent-sdk/streaming-output). | | `codex-exec-jsonl` | Codex 0.163.0 | Prospective `exec` capture; thread, turn and item lifecycle, with updates merged by scoped item ID. No universal timestamps or turn-start IDs; collaboration records need not contain child internals. [Usage](https://learn.chatgpt.com/docs/non-interactive-mode), [pinned emitter](https://github.com/openai/codex/blob/rust-v0.163.0/codex-rs/exec/src/exec_events.rs). | | `opencode-v2-export` | OpenCode 2.1.3; source `bb25a2f4a3f3f04d7c344bec0c0574ea3510b3b1` | V2 `{info, messages}` projected history with tools in assistant `content[]`. V1 `{info, parts}` is unsupported. [Commands](https://opencode.ai/v2/docs/cli/commands/), [transfer schema](https://github.com/anomalyco/opencode/blob/bb25a2f4a3f3f04d7c344bec0c0574ea3510b3b1/packages/schema/src/session-transfer.ts), [message schema](https://github.com/anomalyco/opencode/blob/bb25a2f4a3f3f04d7c344bec0c0574ea3510b3b1/packages/schema/src/session-message.ts). | | `pi-session-jsonl` | Pi 1.4.0; session header schema 3; source `4a080252f06caf63a525d7873c5d94d3c23887e0` | Selected ancestry from the entry graph; tool-call/result IDs are joined. Conversation ancestry is not execution nesting; message timestamps are not tool durations. No universal core subagent format. [Session format](https://github.com/earendil-works/pi/blob/4a080252f06caf63a525d7873c5d94d3c23887e0/packages/coding-agent/docs/session-format.md), [message contract](https://github.com/earendil-works/pi/blob/4a080252f06caf63a525d7873c5d94d3c23887e0/packages/coding-agent/docs/message-types.md). | ### Claude Code: capture new work This starts an agent task. Use it only for work you have already authorized, in an explicitly selected project with `request.txt` containing that task. Use a fresh private capture directory. Keep stderr separate from JSONL. ```sh umask 077 mkdir capture claude --version > capture/producer-version.txt claude -p --verbose --output-format stream-json \ --include-partial-messages --forward-subagent-text \ < request.txt > capture/events.jsonl 2> capture/stderr.log python -m verifair.trace_import --format claude-stream-json \ --source-version 2.1.309 --input capture/events.jsonl --output trace.json ``` Pass your recorded version if it differs. See [programmatic usage](https://code.claude.com/docs/en/headless). Preserve the agent process exit status separately if needed; it is not every tool's exit code. ### Codex: capture new work Use a separate fresh directory, a selected project and an authorized `request.txt`: ```sh umask 077 mkdir capture codex --version > capture/producer-version.txt codex exec --json - < request.txt > capture/events.jsonl 2> capture/stderr.log python -m verifair.trace_import --format codex-exec-jsonl \ --source-version 0.163.0 --input capture/events.jsonl --output trace.json ``` Pass the actual recorded version. See [non-interactive mode](https://learn.chatgpt.com/docs/non-interactive-mode). Keep process exit status separate from tool results. Claude/Codex capture cannot be enabled retroactively. Arbitrary existing interactive histories are unsupported in this release; submit without a trace or use a labelled reconstruction. [Claude SDK history](https://code.claude.com/docs/en/agent-sdk/python#get_session_messages) and [Codex app-server history](https://learn.chatgpt.com/docs/app-server#read-a-stored-thread-without-resuming) are possible future readers, not current import formats. Private history formats have no first-release adapter or compatibility guarantee. ### OpenCode: export selected v2 work ```sh umask 077 opencode --version > producer-version.txt opencode session export ses_EXPLICIT_ID > session.json python -m verifair.trace_import --format opencode-v2-export \ --source-version 2.1.3 --input session.json --output trace.json ``` Substitute the selected session ID and actual producer version. Export selected children separately and add `--input child.json`. No child sessions are discovered automatically. A resumed child with several possible invocations keeps its known session parent but no guessed invocation edge; its history is not duplicated. V1's `opencode export` is a different format. Native `--sanitize` does not guarantee safe publication and may remove child links. See the [pinned exporter/sanitizer](https://github.com/anomalyco/opencode/blob/bb25a2f4a3f3f04d7c344bec0c0574ea3510b3b1/packages/core/src/session/transfer.ts). ### Pi: export the selected branch While Pi is idle, `/session` identifies the selected session file. In its UI, use `/export /absolute/path/current-branch.jsonl` with the explicit `.jsonl` suffix. Then run: ```sh python -m verifair.trace_import --format pi-session-jsonl \ --source-version 1.4.0 --input current-branch.jsonl --output trace.json ``` Use your actual CLI version. A branch export can use its unique leaf. Alternatively, copy one selected full session file and supply `--leaf ENTRY_ID` if it has multiple leaves. Exactly one Pi input is allowed per conversion. The last appended branch is not assumed to be selected. Out-of-file references warn rather than trigger file reads; missing ancestry needed for branch selection is an error. JSONL export rewrites ancestry and gives its header a new timestamp, not the original execution start. `pi --export` produces HTML, not this JSONL. See the [branch exporter](https://github.com/earendil-works/pi/blob/4a080252f06caf63a525d7873c5d94d3c23887e0/packages/coding-agent/src/core/session-export.ts) and [interactive handler](https://github.com/earendil-works/pi/blob/4a080252f06caf63a525d7873c5d94d3c23887e0/packages/coding-agent/src/modes/interactive/interactive-mode.ts). ## Manual reconstruction Use the same normalized schema with `origin: reconstructed`, `source_format: manual` and a nonblank `basis`. This is narrative context, not collected calls. Do not invent outputs, precise timing, models, exit codes, token usage or causal links from memory. A reconstructed tool's status stays `unknown`. This valid synthetic example describes no actual execution. Save it as `trace.json` only to try the format: ```json { "schema_version": 1, "runs": [{ "id": "manual-1", "agent": "other", "origin": "reconstructed", "source_format": "manual", "coverage": "unknown", "warnings": [], "basis": "Synthetic example written by a person; no capture or actual tool results exist." }], "events": [{ "id": "summary-1", "run_id": "manual-1", "ordinal": 1, "kind": "summary", "title": "Illustrative account", "status": "unknown", "explanation": "An account could describe the intended work here. Nothing in this example establishes that it ran." }] } ``` ## Normalized schema v1 The document has exactly `schema_version: 1`, `runs` and `events`. Unknown keys are rejected throughout. Text rejects NUL and lone surrogates; numbers must have the documented type and be finite. There are 1–16 runs, 1–1000 events, at least one event per run and at most 16 combined run/event nesting levels. References must exist, agree on their run and form no cycles. | Run field | Contract | | --- | --- | | `id` | Unique document-local slug, 1–100 characters, `[A-Za-z0-9][A-Za-z0-9._-]*`. | | `agent`, `origin` | `claude_code`, `codex`, `opencode`, `pi` or `other`; `recorded` or `reconstructed`. | | `source_format`, `source_version` | Required nonblank format (max 80 characters); optional producer version (max 80). Omit unknown versions. | | `coverage`, `warnings` | `partial` or `unknown`, never certified complete; up to 20 plain-text warnings of at most 500 characters. | | `basis` | Optional provenance, max 2000 characters; required and nonblank for reconstruction. | | `parent_run_id`, `parent_event_id` | Optional explicit parent run and event in that run. Parent event requires parent run. Unlinked runs are separate roots. | | Event field | Contract | | --- | --- | | `id`, `run_id`, `ordinal` | Unique event slug with the run-ID bounds; existing run; positive integer unique in that run. Ordinals preserve source order, not cross-run time. | | `parent_id` | Optional explicit parent event in the same run. | | `kind`, `title` | `assistant`, `tool`, `checkpoint` or reconstruction-only `summary`; nonblank plain-text title, max 200 characters. Generated tool-name titles are labels, not observed prose. | | `status` | `unknown`, `running`, `completed`, `failed` or `cancelled`. Recorded statuses need source support. Declined execution is cancelled, not evidence of a failed test. | | `model` | Optional recorded identifier, max 100 characters. | | `started_at`, `ended_at` | Optional timezone-bearing RFC3339 timestamps with at most six fractional-second digits, normalized to UTC; end cannot precede start. No required nesting across parent/child clocks. | | `exit_code` | Optional signed integer, only on tools and only from structured source fields. | | `input`, `output`, `explanation` | Optional plain text, max 16000 characters each; displayed as text, not executable HTML or Markdown. Explanation is exposed prose/summary or labelled reconstruction, never hidden thinking. | | `source_ref`, `truncated` | Optional opaque ID, max 200 characters, not an automatic URL/path link; boolean defaults false, true when retained text was shortened. | Reconstructed events cannot contain timestamps, model identifiers or exit codes as observed telemetry. Put uncertain recollections in explanation instead. Missing values stay absent/null. Duration exists only when both timestamps are known. Token/cost accounting is outside v1. Provider-exposed reasoning summaries can be labelled assistant events when details are explicitly included; compaction is a checkpoint, never a reconstruction-only `summary` event. The normalized document is limited to **524288 bytes (512 KiB)** after validation and canonical UTF-8 JSON encoding: Unicode retained, sorted keys, compact separators and no NaN. The whole report request, including body, plan, trace and metadata, remains **1048576 bytes (1 MiB)**. A valid trace can still make a request too large (413). Events are never silently dropped to fit; select fewer with `--last-events` or reduce details. Truncation marks retained text but does not waive the byte limit. ## Inspect and submit Raw prompts, assistant prose, arguments/results and rationale are excluded by default. Tool names, observed lifecycle states, known model names, checkpoints and explicit source relationships remain. `--include-details` permits only bounded, allowlisted text with best-effort removal of common credential keys and bearer values. Binary data, images, environment dumps, unknown objects and hidden/encrypted thinking are never copied. **Inspect the local normalized JSON before submitting.** Redaction is incomplete: names, timestamps and ordinary prose may disclose sensitive information too. Capture files stay local; the server stores only normalized evidence, visible to the same readers as the report with no separate trace access list. Removing it from a new version leaves earlier versions intact. Accidental disclosure needs the instance operator's existing data-removal process, not a new erasure API. After inspection, the [example request builder](https://github.com/romanzdk/verifair/blob/main/docs/examples/trace-report.py) reads only explicitly named local files and writes the JSON request to stdout. Run it with a private output file: ```sh umask 077 python docs/examples/trace-report.py --project my-project \ --title "Describe the change" --risk low --body "$REPORT_MD" \ --trace "$TRACE_JSON" > request.json ``` Add `--plan PATH` for a plan, and `--id SLUG --expected-version N` for a guarded update. A supplied ID alone can still identify a new report. Omitting `--trace` from an implementation body update clears trace evidence for that new snapshot; plan-only updates preserve the existing trace. Review `request.json` before sending it. Then configure `VERIFAIR_URL` and `VERIFAIR_API_TOKEN` privately and send the file with curl, without shell tracing: ```sh curl -sS --fail-with-body -X POST "$VERIFAIR_URL/api/reports" \ -H "Authorization: Bearer $VERIFAIR_API_TOKEN" \ -H "Content-Type: application/json" --data-binary @request.json ``` Use a report body with the instance's required headings. For updates, reuse the report ID, read the latest metadata and add `expected_version` with that `current_version` as an integer (for example, jq's `--argjson expected_version "$EXPECTED_VERSION"` and `expected_version: $expected_version` in the object). Every update carrying a trace object needs this guard, including artifact updates. A new report must omit it. Plan-first implementation also needs the current plan's effective approval; tracing never bypasses that gate. See the [API write table](api.md#optional-execution-evidence) for omission, null and preservation rules, and [Execution](reviewing.md#execution) for reading evidence. Source: https://romanzdk.github.io/verifair/access.md # Roles and access verifair has two modes. `VERIFAIR_OIDC_ISSUER` switches between them. - **Local mode** (no issuer set): no sign-in. People set a name, kept in a cookie. Agents share one `VERIFAIR_API_TOKEN`. Anyone can resolve any thread. Use it on your own machine or in a small trusted group. The settings pages (`/access/tokens` and `/access/members`) only show a note. - **Shared mode** (issuer set): people sign in with your company login (Google, Okta, Keycloak or another OpenID Connect provider), and verifair checks that they are members. Each agent uses a token owned by one member. This is the mode for a team. verifair does the sign-in itself. You do not need an auth proxy in front of it. Microsoft Entra ID is not supported yet: it does not tell verifair whether an email is verified, and verifair needs that. ## Set up shared mode You need: - verifair on an HTTPS URL, for example `https://verifair.example.com`. Set `VERIFAIR_BASE_URL` to it. Sign-in sends people back to `VERIFAIR_BASE_URL/auth/callback`, so it must be the exact URL people open. Plain HTTP only works on `localhost` and `127.0.0.1`. - An OAuth client at your sign-in provider (see [Google](#sign-in-with-google) and [other providers](#other-providers) below). - A session secret: a random string of at least 32 characters. Create one with `openssl rand -base64 48`. Then set: | Variable | Value | | --- | --- | | `VERIFAIR_OIDC_ISSUER` | Your provider's issuer URL, for example `https://accounts.google.com` | | `VERIFAIR_OIDC_CLIENT_ID` | The client ID from your provider | | `VERIFAIR_OIDC_CLIENT_SECRET` | The client secret from your provider | | `VERIFAIR_SESSION_SECRET` | The session secret. Use the same value on every replica | | `VERIFAIR_OIDC_ALLOWED_DOMAINS` | Comma-separated email domains that may sign in, for example `example.com`. Required with Google, optional with other providers | | `VERIFAIR_OPEN_SIGNUP` | `true` lets anyone from the allowed domains join as a member on first sign-in. Default `false`: invite-only. See [Open sign-up](#open-sign-up) | | `VERIFAIR_BOOTSTRAP_ADMIN_ID` | Your own email, for example `jane@example.com`. On a database with no members, startup makes it the first admin, named after the part before `@` until their first sign-in brings the name from your provider. Once members exist, it adds no one. Case does not matter | | `VERIFAIR_BASE_URL` | The HTTPS URL of verifair | `VERIFAIR_API_TOKEN` is not needed. If it is set, shared mode refuses it and startup logs a warning. ```bash export VERIFAIR_BASE_URL=https://verifair.example.com export VERIFAIR_OIDC_ISSUER=https://accounts.google.com export VERIFAIR_OIDC_CLIENT_ID=1234-abcd.apps.googleusercontent.com export VERIFAIR_OIDC_CLIENT_SECRET=... export VERIFAIR_SESSION_SECRET="$(openssl rand -base64 48)" export VERIFAIR_OIDC_ALLOWED_DOMAINS=example.com export VERIFAIR_BOOTSTRAP_ADMIN_ID=jane@example.com uv run uvicorn verifair.asgi:app ``` With compose, put the same settings in a `docker-compose.override.yml` next to `docker-compose.yml`. Compose reads it on its own. `!reset null` (Compose 2.24.4 or newer) removes the API token the main file sets. Keep the secrets in a `.env` file next to it, not in the YAML: ```yaml services: app: environment: VERIFAIR_BASE_URL: https://verifair.example.com VERIFAIR_OIDC_ISSUER: https://accounts.google.com VERIFAIR_OIDC_CLIENT_ID: ${VERIFAIR_OIDC_CLIENT_ID} VERIFAIR_OIDC_CLIENT_SECRET: ${VERIFAIR_OIDC_CLIENT_SECRET} VERIFAIR_SESSION_SECRET: ${VERIFAIR_SESSION_SECRET} VERIFAIR_OIDC_ALLOWED_DOMAINS: example.com VERIFAIR_BOOTSTRAP_ADMIN_ID: jane@example.com VERIFAIR_API_TOKEN: !reset null ``` Compose only starts verifair on `127.0.0.1:8000`. Put an HTTPS reverse proxy (or your cluster's ingress) in front of it for the public URL. ### Sign in with Google 1. Open the [Google Cloud console](https://console.cloud.google.com/) and pick or create a project for verifair. 2. Go to **APIs & Services > OAuth consent screen** (in newer consoles: **Google Auth Platform**). Fill in the app name and a support email. Under **Audience**, pick **Internal** if you use Google Workspace: then only people in your organisation can sign in. Pick **External** only if you need accounts from outside it. 3. Go to **APIs & Services > Credentials > Create credentials > OAuth client ID**. Pick the application type **Web application**. 4. Under **Authorised redirect URIs**, add `https://<your host>/auth/callback`, for example `https://verifair.example.com/auth/callback`. It must match `VERIFAIR_BASE_URL` exactly. You do not need JavaScript origins. 5. Create the client. Copy the **Client ID** into `VERIFAIR_OIDC_CLIENT_ID` and the **Client secret** into `VERIFAIR_OIDC_CLIENT_SECRET`. Set `VERIFAIR_OIDC_ISSUER=https://accounts.google.com`. 6. Set `VERIFAIR_OIDC_ALLOWED_DOMAINS` to your Workspace domain, for example `example.com`. It is required with Google: without it startup fails, because anyone can make a personal Google account for a work email address. verifair accepts only emails in that domain, and only accounts that Google marks as belonging to that Workspace (the `hd` claim). Personal Google accounts are refused. ### Other providers Any provider that supports OpenID Connect with the authorization code flow works. Create a client of type "web" or "confidential", add the redirect URI `https://<your host>/auth/callback`, and copy the client ID and secret. The issuer URL is: | Provider | `VERIFAIR_OIDC_ISSUER` | | --- | --- | | Google | `https://accounts.google.com` | | Microsoft Entra ID | Not supported yet (no `email_verified` claim) | | Okta | `https://<your org>.okta.com` or `https://<your org>.okta.com/oauth2/default` | | Keycloak | `https://<keycloak host>/realms/<realm>` | verifair reads `<issuer>/.well-known/openid-configuration` on the first sign-in, so the issuer must be the exact value the provider puts in its tokens. It asks for the scopes `openid email profile`. verifair only accepts an email that the provider marks as verified (the `email_verified` claim must be `true`), and only an email in plain ASCII. Google, Okta and Keycloak send `email_verified`. Microsoft Entra ID does not, so sign-in through Entra fails with `sign-in failed reason=email`. ### How sign-in works - Opening any page without a session sends you to `/login`, which sends you to the provider. After sign-in you come back to the page you opened. - Your member id is your email in lower case. - Only active members get a session. Someone who signs in but was never added sees a 403 page with the email to add. A deactivated member sees the 403 "turned off" page and is never linked to an account. - A member's first sign-in links them to their account at the provider (the `sub` claim). Later sign-ins with the same email from a different account are refused with `This email is linked to a different account. Ask an admin to reset your sign-in.` An admin fixes that with **Reset sign-in** on the Members page. - verifair keeps you signed in for 7 days with a cookie signed by `VERIFAIR_SESSION_SECRET`. After that you sign in again. Changing the secret signs everyone out. Every replica must use the same secret, or people are signed out when a request reaches another replica. On HTTPS the cookies are named `__Host-vf_session` and `__Host-vf_signin`, so other subdomains cannot set them. - **Sign out** is at the bottom of the sidebar. It ends your sessions in every browser, so a copied session cookie stops working too. It does not end your session at the provider. **Reset sign-in** and deactivation end all of a member's sessions as well. - verifair checks membership on every request. A deactivated member is refused on their next request, even with a valid session cookie. - verifair reads the provider's configuration at the first sign-in after a start and keeps it. If the provider cannot be reached for that first read, verifair waits 30 seconds before it tries again, so sign-in pages fail fast meanwhile. After the configuration is read, a provider that is down shows the "could not reach" page at the end of sign-in, after a 10 second timeout. People who are already signed in keep working. - If you move to a different provider (a new `VERIFAIR_OIDC_ISSUER`), the old links no longer match, so clear them once: `psql "$VERIFAIR_DATABASE_URL" -c "UPDATE users SET oidc_sub=NULL"`. Each member's next sign-in links them again. - verifair checks the ID token it gets straight from the provider over HTTPS: issuer, audience, expiry, nonce, verified email and allowed domains. The sign-in uses `state` and PKCE, so a sign-in started on one browser cannot be finished on another. ### Startup checks Startup prints one line with the reason and exits with code 1 when: - `VERIFAIR_OIDC_ISSUER` is set without `VERIFAIR_OIDC_CLIENT_ID`, `VERIFAIR_OIDC_CLIENT_SECRET` or `VERIFAIR_SESSION_SECRET`, or the session secret is shorter than 32 characters. - The issuer or `VERIFAIR_BASE_URL` is not HTTPS (plain HTTP is only allowed for `localhost` and `127.0.0.1`). - The issuer is Google and `VERIFAIR_OIDC_ALLOWED_DOMAINS` is not set. - `VERIFAIR_OPEN_SIGNUP` is `true` without `VERIFAIR_OIDC_ALLOWED_DOMAINS`, or is not `true` or `false`. - `VERIFAIR_IDENTITY_HEADER` is set. The trusted-header mode was removed. See [Moving from the identity header](#moving-from-the-identity-header). - The database has no members and `VERIFAIR_BOOTSTRAP_ADMIN_ID` is not set. - `VERIFAIR_BOOTSTRAP_ADMIN_ID` is set without `VERIFAIR_OIDC_ISSUER`, so a missing issuer does not quietly start local mode. - The bootstrap id is not an email (it needs an `@`, plain ASCII, no spaces, `/` or `\`). Startup logs `bootstrap admin created id=...` or `bootstrap skipped: members exist`. Secret values are never printed. Once a database has members, it stays in shared mode. Starting it without `VERIFAIR_OIDC_ISSUER` fails with `This database has members. Set VERIFAIR_OIDC_ISSUER to run in shared mode.` This stops a config mistake from opening the workspace to everyone. ### Refusals - A page opened without a valid session goes to sign-in. An htmx request or a form post without one gets 401 `Sign in to use verifair.`, with a **Sign in** link that comes back to the page. - Someone who signs in but was never added gets 403 with a note to ask an admin to add their email, and no session. A deactivated member gets 403 `Your access to this workspace was turned off. Ask an admin to reactivate {id}.` - Each refusal is logged at INFO as `membership denied reason=... path=...`. The reason is `no-session`, `bad-session` (a changed, expired or foreign cookie), `signed-out` (the session was ended by **Reset sign-in** or deactivation), `not-member` or `inactive`. - A failed sign-in shows **Sign-in failed** with a link to try again, and logs `sign-in failed reason=...`. The reason is `no-flow` (the sign-in took longer than 10 minutes or the cookie is missing), `state`, `provider-error` (with the provider's `error` value), `no-code`, `discovery`, `token-endpoint` (with the HTTP status and the provider's `error` value), `id-token`, `iss`, `aud`, `exp`, `iat`, `nonce`, `email` (missing, not verified or not ASCII), `domain`, `account` (the email is linked to a different account), `not-member` or `inactive` (a deactivated member, who gets the 403 "turned off" page and no session). When the provider cannot be reached the page says so and answers 502. When the email, domain or linked account is the problem, the page says that trying again will not help. - If a sign-in fails in one tab while the browser already has a valid session (for example from another tab), verifair goes back to the page instead of showing the error. - `/healthz`, `/readyz`, `/static/`, `/login`, `/auth/callback`, `/logout` and `/signed-out` stay public. `/api/` uses tokens. Every other page, including `/docs` and `/openapi.json`, needs membership. ## What members and admins can do | Action | Member | Admin | Agent token | | --- | --- | --- | --- | | Read all projects, reports and threads | Yes | Yes | Yes | | Push a report or a new version | Through their own agent token | Same as a member | The owner's reports only | | Comment, reply, reopen a thread | Yes | Yes | Comment and reply on the owner's reports | | Request a review, or remove a request | Yes, on any report, from any active member except the report owner. A reviewer with a current verdict on either review target cannot be removed | Same as a member | Add requests on the owner's reports when pushing | | Sign a version | Own reports | Own reports | No | | Approve or request changes | Reports someone else signed | Same as a member | No | | Resolve a thread or change its blocking flag | Threads they started, and agent threads on their own reports | Any thread | Resolve agent threads on the owner's reports | | Create and revoke agent tokens | Own tokens | Own tokens, and revoke anyone's | No | | Add members, change roles, deactivate, reset sign-in | No | Yes | No | ## Members and roles Admins manage members on the **Members** page (`/access/members`, under **Settings** in the sidebar). Other members cannot open it. To add a member, enter their email, a display name, and a role: `admin` or `member`. Ids are matched without regard to case: verifair stores them in lower case and turns the email from sign-in into lower case too, so `Jane@Example.com` and `jane@example.com` are the same member. The members table changes roles and deactivates or reactivates people. Members are never deleted, so their history stays. When email is set up (see [configuration.md](configuration.md)), adding a member sends them a short email: who added them, the sign-in link, and the **Agent tokens** page where they connect their agent. A deactivated member is refused on their next request, is signed out in every browser, and all their tokens are revoked. Reactivating them does not bring old tokens or sessions back. **Reset sign-in** forgets which provider account a member is linked to and signs them out in every browser. Use it when someone's account at the provider changed, or when you think someone else signed in as them. Their next sign-in links them again. A reset alone lets whoever signs in next with that email claim the member, so if you think someone else signed in as them, use this order: 1. Deactivate the member. verifair never links or signs in a deactivated member, and their agent tokens are revoked. If they are the only admin, make someone else an admin first. 2. Fix or remove the wrong account at the provider. 3. **Reset sign-in**. 4. Reactivate the member. Their next sign-in links them again. There must always be at least one active admin. verifair refuses to demote or deactivate the last one: `There must be at least one active admin.` Admin and token actions (a reset logs `action=reset_sign_in`), and added or removed review requests (`action=add_reviewer` and `action=remove_reviewer`, with `report=<id>`), are written to the app log as `access action=... actor=... target=...`. A member change also logs `tokens_revoked=N`, the number of tokens a deactivation revoked. The token secret is never logged. ### Open sign-up By default verifair is invite-only: someone who is not a member gets 403 with the email an admin has to add. With `VERIFAIR_OPEN_SIGNUP=true`, anyone whose email is in `VERIFAIR_OIDC_ALLOWED_DOMAINS` becomes a member with the role `member` on their first sign-in, named after the name the provider sends, cleaned of invisible characters (without a readable name, the part of the email before `@`). Members added by an admin keep the name the admin typed. Their sign-in logs `access action=join target=...`. The Members page says when open sign-up is on. - It needs `VERIFAIR_OIDC_ALLOWED_DOMAINS`, so only your own domains can join. - Deactivated members stay out. Open sign-up never reactivates anyone. - Admins still choose who is an admin. Adding someone first still works, to give them a role or name before they sign in and to send them the welcome email. ## Agent tokens Every member creates their own agent tokens on the **Agent tokens** page (`/access/tokens`, under **Settings** in the sidebar). `/access` takes you there. A token looks like `vf_<id>.<secret>`. It is shown once, right after you create it. Copy it then; verifair keeps only a hash. Send it as `Authorization: Bearer vf_...`. - The **Agent tokens** page lists your tokens with when each was created and last used (**Last used** shows `Never` until an agent uses it, and is updated at most once a minute). Revoke a token there when you no longer need it. Admins also see every member's tokens and can revoke any of them. - Deactivating a member revokes their tokens. - A token acts for its owner, but never with admin power, even when the owner is an admin. It cannot push to or comment on another member's report, and it cannot resolve threads that a person started. - The shared `VERIFAIR_API_TOKEN` is refused in shared mode with 401 `The shared token is not accepted in shared mode. Use a member agent token.` - Each refused token is logged at INFO as `api token refused reason=... path=...`. The reason is `missing` (no `Authorization` header), `malformed` (not a `Bearer` value, or a `vf_` token of the wrong shape), `shared-token` (a bearer value that does not start with `vf_`), `unknown` (no such token, or a wrong secret), `revoked` or `inactive` (the owner is deactivated). The token itself is never logged. ## Report ownership The token owner owns every report the token pushes. Report ids are chosen by the client. If an id is already used by another member's report, the push gets 403 `This report belongs to another member.` Pick a different id, or leave `id` out and verifair makes a new one. Ownership cannot be transferred. A report with no owner (a report pushed in local mode and never signed) is frozen in shared mode. Nobody can sign it, push a new version to it, or comment on it with an agent token. People can still read it, comment and request reviews in the browser. To go on, push it again with an agent token under a new id. ## Moderation For plan-first reviews these permissions apply separately to the plan and implementation. Shared-mode owners sign each target, and another person gives its verdict. Agent tokens can submit and discuss their owner's documents but cannot sign or approve either one. Implementation attachment, signing and verdicts wait for effective approval of the current plan. Admins have no bypass. In local mode the existing cookie identities and signing rules still apply; they do not provide strong authentication. In shared mode, anyone can reply to a thread or reopen it with the **Reopen** button. Only these people can resolve a thread or change its blocking flag: - the person who started the thread, - an admin, - the report owner, when the agent started the thread. A reply to a resolved thread opens it again only when the person replying could resolve it. The same rule holds for agent tokens: the owner's agent reopens a thread by replying only when the agent started it. So an agent that answers "fixed" on a reviewer's resolved thread does not open it again, but the reviewer's own reply does. In local mode every reply opens the thread again. The buttons are hidden for everyone else. If someone else tries anyway, a request without htmx gets 403 `Only the person who started this thread, the report owner for agent threads, or an admin can do that.`, and a request from the page shows the same text in the thread instead. A resolved thread shows who resolved it. In local mode, anyone can resolve any thread. ## Switching from local to shared mode A deployment that starts with `VERIFAIR_OIDC_ISSUER` set runs in shared mode. Before you switch: 1. Back up the database with `pg_dump -Fc "$VERIFAIR_DATABASE_URL" > verifair.dump`. 2. Set up sign-in as in [Set up shared mode](#set-up-shared-mode), including `VERIFAIR_BOOTSTRAP_ADMIN_ID`, or startup fails. 3. Point the liveness probe at `/healthz` and the readiness probe at `/readyz`. They need no sign-in. 4. Plan who to add first. After the switch, everyone except the bootstrap admin gets 403 until an admin adds them. The 403 page shows the exact email to add, so ask people to open verifair and send it to you. After the switch: - The bootstrap admin is already a member. Add the other members on `/access/members` and give each agent a member token. The old shared token stops working. - The first start in shared mode turns the stored ids into lower case once: report owners (`author_id`), requested reviewers, signers, verdict and comment authors, and `resolved_by`. Local mode stores them as they were sent, and shared mode matches them to the email from sign-in in lower case. Startup logs `lowercased ids rows=N`. Later starts change nothing. An old id that would turn into `agent` stays as it is. - Existing reports keep their `author_id`. A member can push new versions only to reports whose `author_id` is their id. - Reports with no owner are frozen. Push them again with an agent token under a new id. - If no admin can sign in any more, make someone admin again in the database. Use the email in lower case: ```bash psql "$VERIFAIR_DATABASE_URL" -c "UPDATE users SET role='admin', active=1 WHERE id='jane@example.com'" ``` ## Moving from the identity header Older versions trusted a header from an auth proxy (`VERIFAIR_IDENTITY_HEADER`, often with oauth2-proxy). That mode was removed, and startup now fails while the variable is set. 1. Make sure an active admin's member id is the email they sign in with. Sign-in only knows emails, and `VERIFAIR_BOOTSTRAP_ADMIN_ID` adds no one once members exist, so otherwise nobody can sign in as admin. To add such an admin, or to make an existing member one, run (with the email in lower case): ```bash psql "$VERIFAIR_DATABASE_URL" -c "INSERT INTO users (id, name, role, created_at, updated_at) VALUES ('jane@example.com', 'Jane', 'admin', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS+00:00'), to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS+00:00')) ON CONFLICT (id) DO UPDATE SET role='admin', active=1" ``` 2. Set up sign-in as in [Set up shared mode](#set-up-shared-mode). You can reuse the OAuth client the proxy used: add the redirect URI `https://<your host>/auth/callback` to it. 3. Remove `VERIFAIR_IDENTITY_HEADER` and take the auth proxy out. Keep your HTTPS reverse proxy or ingress. 4. Member ids stay the same when the proxy sent the email (for example `X-Forwarded-Email`). If it sent something else, such as a user name, add each person again under their email; the old member cannot be renamed. 5. Probes and `/api/` no longer need proxy skip rules. ## Not built yet - An audit log in the app. - Token scopes and token expiry. - Ownership transfer. - Admin overrides, such as resolving with a recorded reason. - Moving local-mode identities to member ids. - Moving data from an old SQLite deployment. Source: https://romanzdk.github.io/verifair/configuration.md # Configuration verifair reads its settings from environment variables. | Variable | Default | Meaning | | --- | --- | --- | | `VERIFAIR_API_TOKEN` | required in local mode | Bearer token agents use for `/api` in local mode. Not accepted in shared mode (startup logs a warning if it is set) | | `VERIFAIR_DATABASE_URL` | required | Postgres connection URL (libpq format), for example `postgresql://verifair:verifair@localhost:5432/verifair` | | `VERIFAIR_DB_POOL_SIZE` | `10` | Most database connections one verifair process opens. A request waits up to 10 seconds in total for a free one, then gets 503 with `Retry-After` | | `VERIFAIR_BASE_URL` | `http://localhost:8000` | Used for links in API responses and notifications. In shared mode it must be the HTTPS URL people open: sign-in sends them back to `VERIFAIR_BASE_URL/auth/callback` | | `VERIFAIR_REQUIRED_SECTIONS` | `Verification,Rollback,Security impact` | Comma-separated H2 headings every pushed body must have; empty turns the check off | | `VERIFAIR_OIDC_ISSUER` | unset | Issuer URL of your OpenID Connect sign-in provider, for example `https://accounts.google.com`. Setting it turns on shared mode (see [access.md](access.md#set-up-shared-mode)). Must be HTTPS, except `localhost` and `127.0.0.1` | | `VERIFAIR_OIDC_CLIENT_ID` | unset | Shared mode: client ID of the OAuth client at your provider. Required when the issuer is set | | `VERIFAIR_OIDC_CLIENT_SECRET` | unset | Shared mode: client secret of that OAuth client. Required when the issuer is set | | `VERIFAIR_SESSION_SECRET` | unset | Shared mode: secret that signs the session cookie, at least 32 characters, the same on every replica. Create one with `openssl rand -base64 48`. Changing it signs everyone out. Required when the issuer is set | | `VERIFAIR_OIDC_ALLOWED_DOMAINS` | unset | Shared mode: comma-separated email domains that may sign in, for example `example.com`. Required with Google, and the account must also belong to one of these Workspace domains. Unset means any verified email can sign in (it still needs to be a member) | | `VERIFAIR_OPEN_SIGNUP` | `false` | Shared mode: `true` makes anyone whose email is in `VERIFAIR_OIDC_ALLOWED_DOMAINS` a member (role `member`) on their first sign-in, so admins do not have to add everyone. Off means invite-only. Startup fails when it is `true` without allowed domains, or not `true` or `false` | | `VERIFAIR_BOOTSTRAP_ADMIN_ID` | unset | Shared mode: email of the first admin, created when the database has no members. Startup fails if it is set without `VERIFAIR_OIDC_ISSUER` | | `VERIFAIR_IDENTITY_HEADER` | removed | The trusted-header mode was removed. Startup fails while it is set. See [access.md](access.md#moving-from-the-identity-header) | | `VERIFAIR_SLACK_WEBHOOK_URL` | unset | Optional Slack webhook for draft reports, ready-for-review notifications, comments and verdicts | | `VERIFAIR_SMTP_URL` | unset | Optional mail server for review request emails (shared mode only, see [reviewing.md](reviewing.md#emails); in local mode startup logs a warning and no email is sent). `smtp://user:password@host:587` uses STARTTLS when the server offers it; `smtps://user:password@host:465` uses TLS from the start. Encode special characters in the user and password (`@` is `%40`). Without a port, `smtp://` uses 25 and `smtps://` 465. Startup fails when the URL is not `smtp://` or `smtps://` with a host | | `VERIFAIR_EMAIL_FROM` | unset | Sender address of those emails: one plain address, for example `verifair@example.com`. Required when `VERIFAIR_SMTP_URL` is set; startup fails when it is not one plain address | For a remote mail server use `smtps://`, or `smtp://` to a server that offers STARTTLS. verifair never sends the user and password without TLS: when a `smtp://` URL has a user and the server does not offer STARTTLS, it logs one warning (`email not sent report=<id> recipients=<n>: the server offers no TLS, ...`) and sends nothing. `smtp://` without a user still sends without TLS, for a local relay. verifair checks the server's certificate in both modes. The URL, password and addresses are never logged; a failed email logs `email failed report=<id> recipients=<n> error=<error type> code=<SMTP code or ->`, one line per recipient the server refuses, or one for the rest of the batch when the connection or login fails, and the request that sent it still succeeds. At most two emails are sent at the same time. The Docker image also uses these uvicorn settings: | Variable | Default in the image | Meaning | | --- | --- | --- | | `UVICORN_HOST` | `0.0.0.0` | Address to listen on. Set `127.0.0.1` when a TLS proxy runs next to verifair in the same pod. See [deployment.md](deployment.md#docker) | | `FORWARDED_ALLOW_IPS` | `*` | Which addresses uvicorn trusts for `X-Forwarded-*` headers. Set it to your proxy's IP. See [deployment.md](deployment.md#deploying) | Source: https://romanzdk.github.io/verifair/deployment.md # Deployment - [Run from source](#run-from-source) - [Docker](#docker) - [Deploying](#deploying) - [Upgrading an existing HTTP deployment](#upgrading-an-existing-http-deployment) ## Run from source To run from source, start only the database and point verifair at it: ```bash docker compose up -d db uv sync export VERIFAIR_API_TOKEN=change-me export VERIFAIR_DATABASE_URL=postgresql://verifair:verifair@localhost:5432/verifair uv run uvicorn verifair.asgi:app ``` Every start runs the schema SQL, which is safe to repeat. It runs under an advisory lock, so several copies can start at once. `CREATE INDEX IF NOT EXISTS` takes a lock that waits behind writes in progress, so a start can wait up to 5 seconds. After that it stops with `Startup waited too long for a database lock held by another transaction. Restart to try again.` Plan-first reviews add a fixed workflow and target-specific review records, allow a snapshot without an implementation, and backfill the revision associated with each saved plan. Existing reports remain artifact reviews; existing comments, signatures and verdicts remain implementation records. The migration preserves saved Markdown and is safe to repeat under the same startup lock. Back up first, stop older application instances, and restart all instances on this version before accepting plan-first submissions. Do not mix older readers or writers with migrated data: they cannot inherit plans correctly and assume every snapshot has an implementation body. Downgrading is not supported after plan-first records exist; restore the backup into a separate empty database if a rollback is needed. Keep the migrated database for its review history. Older versions kept their data in a SQLite file, `verifair.db`. This version does not read that file or move its data. Start with an empty database. ## Docker CI publishes the image to `ghcr.io/romanzdk/verifair` for `linux/amd64` and `linux/arm64`. It is public, so pulling needs no login. | Tag | Published on | | --- | --- | | `main`, `sha-<short commit>` (for example `sha-1787484`) | every push to `main` | | `1.2.3`, `latest`, `sha-<short commit>` | a `v1.2.3` git tag on a commit in `main` | Other git tags, like `v1.2` or `v1.2.3-rc.1`, fail the publish job and publish nothing. Deploy a version tag or `sha-<short commit>`, not `main` or `latest`, so a restart does not pull a different version. To build it yourself, run `docker build -t verifair .`. ```bash docker run --rm -e VERIFAIR_API_TOKEN=change-me \ -e VERIFAIR_DATABASE_URL=postgresql://verifair:verifair@host.docker.internal:5432/verifair \ -p 127.0.0.1:8000:8000 ghcr.io/romanzdk/verifair:1.2.3 ``` The image runs as user `10001`, not root, so it works with Kubernetes `runAsNonRoot: true`. With `readOnlyRootFilesystem: true`, mount an `emptyDir` at `/tmp`, because `uv run` writes there at start. This needs a running Postgres. `host.docker.internal` reaches a Postgres on your own machine (Docker Desktop). On Linux, or for another server, replace it with the database host. The image listens on all addresses (`UVICORN_HOST=0.0.0.0`). When a TLS proxy runs next to verifair in the same pod, you can set `UVICORN_HOST=127.0.0.1` so only the proxy can reach it. The image `HEALTHCHECK` still works then, because it runs inside the container. ## Deploying - In local mode the web UI has no login. Anyone who can open it can comment and give verdicts. Run it on a private network or behind an auth proxy (SSO, basic auth). For a team, use shared mode, where verifair signs people in itself with your OpenID Connect provider (see [access.md](access.md#set-up-shared-mode)). The Docker example above only listens on `127.0.0.1`. - HTTPS is required for browser access outside loopback, including private-network hostnames. Local development may use HTTP on `localhost`, `127.0.0.1`, `[::1]` or `0.0.0.0`. In local mode, on these addresses (any `127.x.x.x` too), a browser post without `Sec-Fetch-Site` is accepted when its `Origin` matches the address, because some browsers leave the header out on `http://0.0.0.0`. Put a TLS reverse proxy in front of the loopback listener for shared deployments. Ordinary HTTP hostnames can load pages but browser writes return 403 because browsers omit the required `Sec-Fetch-Site` header on those origins. - To sign people in, set `VERIFAIR_OIDC_ISSUER`, `VERIFAIR_OIDC_CLIENT_ID`, `VERIFAIR_OIDC_CLIENT_SECRET` and `VERIFAIR_SESSION_SECRET` (see [access.md](access.md#set-up-shared-mode)). This turns on shared mode. The email from sign-in becomes the user id; the display name comes from the member list. The name prompt goes away, and signing and approvals are tied to that identity. No auth proxy is needed. - With several replicas, give every replica the same `VERIFAIR_SESSION_SECRET`. A replica with another secret does not accept the session cookie and sends people to sign in again. - verifair must be able to reach the provider over HTTPS (for Google: `accounts.google.com` and `oauth2.googleapis.com`) for sign-in. Already signed-in people keep working while the provider is down. - Set `VERIFAIR_BASE_URL` to the public HTTPS URL, for example `https://verifair.example.com`. It is used in notification links, in the push response and as the sign-in redirect URI (`VERIFAIR_BASE_URL/auth/callback`), and enables secure cookies. Shared mode refuses to start with an HTTP base URL other than `localhost`. Keep the browser on this origin and have the reverse proxy preserve `Sec-Fetch-Site`. - Serve it at the root of a domain. Sub-paths (like `/verifair/`) are not supported. - verifair needs a Postgres 16 or newer database. Its database role must own the schema and the tables, because every start runs the schema SQL, and Postgres 15 and newer does not let every role create tables in `public`. Where Postgres runs in production is not decided here. - For a database on another host, add `sslmode=verify-full` (or at least `sslmode=require`) to `VERIFAIR_DATABASE_URL`, for example `postgresql://verifair:secret@db.example.com:5432/verifair?sslmode=verify-full`. - Behind PgBouncer, use session mode. Transaction mode needs PgBouncer 1.21 or newer with `max_prepared_statements` set, because psycopg makes prepared statements and they cannot be turned off from the URL. - Startup creates and updates the schema under an advisory lock, so several replicas can start at once. A start can wait up to 5 seconds behind writes in progress, then stops with a clear message. Restart it to try again. - You can run several replicas, because all state is in Postgres. Keep `(replicas + extra pods during a rolling update) × VERIFAIR_DB_POOL_SIZE`, plus the connections you need for admin work and backups, below the server's `max_connections`. - Back up with `pg_dump -Fc "$VERIFAIR_DATABASE_URL" > verifair.dump`. Restore into an empty database as verifair's role with `pg_restore --no-owner -d <empty database> verifair.dump`, so that role owns the restored tables. - The image trusts `X-Forwarded-*` headers from any address. Set `FORWARDED_ALLOW_IPS` to your proxy's IP. - Use `/healthz` for the liveness probe and `/readyz` for the readiness probe. `/healthz` does not touch the database, so a database outage does not restart pods. `/readyz` returns 200 `{"ok": true}` when the database answers `SELECT 1`, and 503 `{"ok": false}` with `Retry-After: 5` when it is down or no connection is free within 2 seconds, so the pod stops getting traffic until the database is back. Both are public, also in shared mode. - Give both probes a timeout of at least 5 seconds (`timeoutSeconds: 5`). Under heavy load the answer can take a few seconds, and a shorter timeout restarts a pod that is only busy. - The probes need no sign-in and no proxy rules. With `UVICORN_HOST=127.0.0.1`, an HTTP probe from outside the container cannot reach `/healthz`; use an exec probe with the same command as the image `HEALTHCHECK` then. ### Upgrading an existing HTTP deployment Before upgrading a shared deployment, configure a trusted TLS certificate and an HTTPS reverse proxy for its hostname. Redirect the old HTTP URL to HTTPS, set `VERIFAIR_BASE_URL` to that HTTPS origin, and restart verifair behind the proxy with its existing database. Update agent URLs and bookmarked report links to HTTPS. A healthy `/healthz` response or a readable page on HTTP does not confirm that browser writes work. After restarting, open the HTTPS URL in a browser. In local mode, set your name if needed; in shared mode, sign in as a member first. Then submit a comment on a draft report to check that writes pass through the proxy. Existing non-loopback HTTP deployments need this change before the upgrade; private-network placement alone does not enable browser writes. The API continues to use bearer tokens, and `/healthz` can still be checked over the internal loopback HTTP listener. Source: https://romanzdk.github.io/verifair/changelog.md # Changelog All notable changes to verifair are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html): a minor version adds features, a patch version only fixes bugs. Every pull request adds a line under **Unreleased**. A release moves those lines into a new version section. ## [Unreleased] ### Added - Each version tag creates a GitHub release, with this changelog's section as its notes. ## [0.5.0] - 2026-11-06 ### Added - A **System / Light / Dark** theme switch at the bottom of the sidebar. The choice is saved per browser; System follows the operating system. ## [0.4.0] - 2026-11-06 ### Added - [Conventional Comments](https://conventionalcomments.org) labels: buttons above the comment box add `praise:`, `nitpick:`, `suggestion:`, `issue:`, `question:`, `thought:`, `todo:`, `chore:` or `note:` to the comment. `issue` and `todo` make it blocking, the others non-blocking. Threads show the label as a badge; it is stored as plain text, so agents read it as is. ## [0.3.0] - 2026-11-06 ### Added - **Unapprove** and **Withdraw request**: after you approve or request changes, that button turns into its undo and removes your verdict on the version. - **Unsign**: the signer or an admin can unsign the current version while nobody has given a verdict on it. - Withdrawing a verdict posts to Slack, like giving one. - **Docs** and **GitHub** links at the bottom of the sidebar. ### Changed - Clearer dividers, spacing and numbered headings between the sections of a review. - In a plan-first review, the plan's approval and signature can no longer be undone once the implementation is attached. ### Fixed - After approving, the **Approve** button stayed active and could record the same verdict again. - The API explorer at `/docs` and `/redoc` was blank, because the content security policy blocked its scripts. ## [0.2.0] - 2026-11-06 ### Changed - The skill is installed with [`npx skills`](https://github.com/vercel-labs/skills), pinned to the server's version, so it works for Claude Code, Codex, Cursor and other agents. `GET /api/skill` stays as a fallback without Node.js. - Clearer navigation: **Assigned to me** (you are the reviewer), **Created by me** (you are the author) and **All reviews**. The app now says "review" for the document everywhere. ## [0.1.3] - 2026-11-06 ### Added - **Archive** and **Restore** a review: it leaves the Inbox, lists and sidebar but stays readable at its link. The owner, requested reviewers and admins can do it, also through the API (`POST /api/reports/{id}/archive` and `/restore`). An **Archived** tab finds archived reviews again. - **Archive project** (admins): archives every review in a project. - **Delete permanently** (admins): removes a review and everything on it, after typing its id. ## [0.1.2] - 2026-11-06 ### Added - Onboarding: the first-run page and the Agent tokens page show how to install the skill (`GET /api/skill`), and members without a token see a "Connect your coding agent" banner. - A welcome email when an admin adds a member. - The new token is filled into the `export` lines right after you create it. - Optional open sign-up (`VERIFAIR_OPEN_SIGNUP=true`): anyone from the allowed domains becomes a member on first sign-in. Off by default. ## [0.1.1] - 2026-11-06 ### Fixed - Resolve, Reopen and Mark blocking now work without JavaScript. - A blank comment shows "The comment is empty" instead of a vague error. - Pages are no longer cached by browsers or proxies (`Cache-Control: no-store`), and HTTPS deployments send `Strict-Transport-Security`. ## [0.1.0] - 2026-11-05 ### Added - First release. Coding agents publish change reports through a JSON API; people sign them, comment on any text, and approve or request changes, with versions, blocking threads and an Inbox grouped by whose turn it is. - Plan-first reviews, optional execution traces, local and shared mode with Google or OpenID Connect sign-in, members, admins and per-member agent tokens, optional Slack messages and review emails. - Docker image at `ghcr.io/romanzdk/verifair` and the `verifair-review` agent skill. [Unreleased]: https://github.com/romanzdk/verifair/compare/v0.5.0...HEAD [0.5.0]: https://github.com/romanzdk/verifair/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/romanzdk/verifair/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/romanzdk/verifair/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/romanzdk/verifair/compare/v0.1.3...v0.2.0 [0.1.3]: https://github.com/romanzdk/verifair/compare/v0.1.2...v0.1.3 [0.1.2]: https://github.com/romanzdk/verifair/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/romanzdk/verifair/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/romanzdk/verifair/releases/tag/v0.1.0