Run evidence and permanent audit records
Project Synapse keeps the handoff from a user to an agent visible and
diagnosable. A submitted run receives a UUID immediately, and that UUID opens a
permanent owner-scoped record at /runs/<run-id>. Text, documents, screenshots,
model evidence, cost evidence, lifecycle timestamps, safe diagnostics, and the
output are tied to that one run.
This capability is intended for synthetic, non-sensitive M1 demonstrations.
It does not by itself authorize company-confidential, regulated, patient, or
production data. Enterprise data use still requires the provider, retention,
region, classification, and company approvals in plan.md.
What a user sees
- The composer accepts ordinary text blocks and screenshot cards. Pasting an image at the cursor splits the active text block, inserts the image at that exact position, and leaves a text block after it. Drop and file selection use the same ordered model.
- Each screenshot can have a plain-text caption and can be replaced, removed, or moved with visible keyboard-operable controls.
- Submission creates the run before screenshot transfer begins. The receipt displays the complete run UUID, Accepted time, current state, Copy action, and permanent-record link. A transfer or browser-network failure does not erase that handoff.
- The UI distinguishes Preparing from Streaming. An evidence-backed run is not shown as executing until finalization has activated the run or a durable run event proves that execution started.
- A transient screenshot-transfer failure can retry the same run while the browser still holds its short-lived in-memory capability. Retry never creates a second run. Cancel closes the original waiting run. After finalization, upload authority is destroyed and a dropped response follows the original run through its authenticated event stream.
The permanent record shows the submitted input in authored order, Accepted, Started, and Ended times in the viewer's browser timezone, duration, execution mode, attempt, pinned agent version, support trace ID, the latest allowlisted provider diagnostic, document names, private screenshots, model/cost evidence, and output. Its Steps view also holds an Advisor consults block when the run consulted an advisor model, and an Answer check section when the run's answer was checked. It never renders a Storage path, signed token, unrestricted event payload, raw execution snapshot, authorization header, or service credential.
Composer narrative field (merged writing surface)
Some agents mark one type:'string' input as format:'composer' (for example
issue-analyzer.symptom). That field renders AS the fluid evidence surface at
its position in the run form, so a user writes the narrative and drops
screenshots in one place. The named input contract is unchanged: workflows,
prompt assembly, and the permanent record still see the field. Agents without a
composer field (for example incident-classifier) keep the standalone
"Evidence & context" section. A schema that marks more than one composer field
fails closed — the agent page returns 404 and the run POST returns HTTP 500.
A composer run travels in one of two shapes:
- Text-only draft (no screenshots). The browser sends NO evidence manifest
and puts the joined narrative directly into
input[field]. This is the ordinary plain-input path — zero evidence rows are created, and the prompt renders the narrative in full under## Inputs. - Image draft (one or more screenshots). The browser sends the evidence
manifest and the SERVER derives
input[field]from the manifest's ordered text blocks via the shared pure helpercomposerTextFromBlocks(client and route call the same function, so they agree on the stored value). The client's own value for the composer field is always ignored when a manifest is present. A non-empty derived narrative is stored onruns.input[field]; an images-only draft (derived text empty after trim) drops the key, so a required composer field fails Ajv validation with the exactMissing required field: <name>(HTTP 422) — identical to an empty required textarea. The client pre-flights the same required-empty rule with a friendly inline error before creating a run.
Single-send guarantee. The model receives the narrative exactly once. When a
run has ordered evidence rows, the engine renders the composer field in the
prompt as a placeholder line (- **<field>**: provided as the ordered walkthrough below) and the narrative arrives once through the ordered evidence
blocks (content[0] carries the placeholder prompt, keyed off the actual
presence of evidence rows, not off the schema alone). A text-only run has no
evidence rows, so it renders the full narrative in ## Inputs and no placeholder
is used. Pre-merge runs whose pinned snapshot has no composer field, and all
workflow agent steps, keep the full renderInput behavior unchanged.
60,000-character note. serializeEvidenceDraft caps the combined evidence
text at 60,000 characters. Because the shared helper joins text blocks with a
blank line (\n\n), the derived narrative stored in runs.input[field] may
exceed 60,000 by at most 40 joiner characters (two per join, at most 20 joins
for 21 blocks). This is deliberate: runs.input has no per-field length cap, so
NO second cap is added — the small joiner overflow is accepted rather than
introducing a divergent limit.
Supported evidence limits
| Control | M1 limit |
|---|---|
| Screenshot count | 5 per run |
| Accepted source formats | PNG, JPEG, WebP |
| Declared source size | 5 MiB per screenshot |
| Ordered evidence blocks | 21 |
| Combined evidence text | 60,000 characters |
| Caption | 500 characters per screenshot |
| Complete multipart run request | Conservatively estimated at no more than 4,400,000 bytes |
| Source safety | Static images only; animated and oversized decoded inputs fail |
| Normalized result | Lossless WebP, metadata removed, longest edge at most 1,568 px |
Document bytes retain their 4 MiB combined limit, and the complete multipart request has a second stricter estimator that includes UTF-8 structured input, evidence JSON, multibyte filenames, MIME values, document bytes, and conservative per-part/framing overhead. Requests above 4,400,000 estimated bytes fail before run creation, leaving margin below Vercel's 4.5 MB edge limit. Screenshot bytes do not cross the Vercel Function request body. The browser uploads them directly to exact private paths using short-lived signed upload capabilities.
Private upload and activation lifecycle
The client first sends a versioned manifest containing only ordered blocks and declared image metadata. The server creates a waiting run and immutable evidence rows, then returns exact-path upload capabilities. The browser validates that the response has the same run UUID, exact asset count, asset identifiers, and canonical paths before uploading.
Capabilities exist only in memory. They are not persisted in local storage, rendered into the page, logged, or placed in navigation URLs. They are zeroized as soon as finalization proves a same-run execution handoff. The Storage bucket is private and browser users have no direct table-write permission.
Finalization downloads each staged object through the service boundary and checks declared byte length, decoded MIME/magic, animation, source dimensions, and decoded pixel count. It auto-orients, constrains the image, strips metadata, creates a deterministic lossless WebP, and records its byte length, dimensions, and SHA-256. Only normalized ready images reach a model or the authenticated evidence-delivery route. A run, its ready evidence, and the queued event activate atomically.
The preparation window is five minutes. Staging deletion waits until two hours after that deadline so a late signed upload cannot recreate an object after it was cleaned. The worker and normal run intake perform bounded cleanup batches. The database lists candidates; the application removes Storage objects and only then acknowledges cleanup. Ambiguous finalization never request-deletes a normalized object that may already be referenced; terminal unreferenced orphans are handled by the aged cleanup path.
Model ordering and cost control
The model receives one ordered user message whose text and normalized images match the authored block order. A top-level system instruction declares evidence to be untrusted data, not executable instruction. Refinement reuses the same immutable evidence sequence.

A completed multimodal run's ordered evidence — numbered text and image blocks preserved in the exact order the user authored them.
Binary bytes are replaced by a fixed sentinel only in the accounting copy used
for UTF-8 envelope estimation; they are never serialized as prompt text. The
four approved multimodal profiles, anthropic/claude-haiku-5.5,
anthropic/claude-sonnet-5.5, anthropic/claude-opus-5.5 and
anthropic/claude-fable-5.1, each reserve 3,136 visual input tokens per image
for every provider attempt and retry. All four models read
images in 28 px patches in the high-resolution tier, and a normalized image is
at most 1,568 px on each edge, so one image costs at most 56 x 56 = 3,136
tokens. It also caps the
complete encoded multimodal envelope at 24 MiB—counting system/text/tools plus
exact base64 expansion—before reservation or provider invocation, leaving an
8 MiB margin below the upstream 32 MiB request boundary. Provider-reported usage
settles the existing input-token meter. AI SDK step results explicitly do not
retain the provider request body. An unknown real multimodal model profile fails
before model construction or paid provider work; text-only and mock paths keep
their existing behavior.
Diagnostics and support workflow
Users receive a generic provider failure message. The permanent record may show only these provider categories:
authenticationbilling_or_quotapolicyinvalid_requestinvalid_responsemodel_unavailabledependency_unavailablerate_limitedtimeoutupstream_unavailableunknown
The diagnostic also contains only a valid HTTP status or null, a retryable
boolean, and gateway, api_call, sdk_retry, or unknown as the error type.
Provider bodies, messages, URLs, headers, causes, stacks, and retry history are
not copied into run records or terminal events. Evidence query, Storage, decode,
and byte-read failures similarly collapse to one cause-free readiness message.
The diagnostic query filters to events that contain provider evidence before it orders and limits results, so ordinary refinement failures cannot hide an older valid provider diagnostic. A deterministic screenshot validation rejection is not retryable with the same immutable upload; the UI offers cancel/start-new guidance instead. Missing or ambiguous transfers remain same-run retryable while their in-memory capability is valid.
For support, ask the user for the run UUID or the displayed 32-hex trace ID. Operators may correlate those identifiers with structured server logs and the append-only run event sequence. Do not ask the user to share signed upload tokens, Storage paths, credentials, or raw provider responses. Do not copy screenshot contents into tickets or Obsidian unless the company's data-handling policy explicitly permits it.
Authorization and caching
Run records, evidence rows, and the image-delivery route require the run owner or an administrator. Unknown, unauthorized, pending, or inconsistent evidence uses a generic not-found/not-ready boundary. Images are served with private, no-store, nosniff, and cross-origin-resource-policy headers from the same-origin authenticated route. No public Storage policy or signed read URL is used.
The permanent run page and image response are not a public asset cache. Any future CDN, thumbnail, export, or sharing feature must receive its own data classification, authorization, retention, revocation, and audit design before implementation.
Audit evidence
PlatformAdmin and Auditor can open a run from the audit explorer at
/admin/audit to see its evidence. The view shows identifiers, statuses,
counts, timestamps, allowlisted diagnostics, and the run's events in the
tamper-evident audit ledger. It never shows raw input, output, retrieved
text, or tool arguments or results; those stay on this permanent run record.
Estimated cost appears for PlatformAdmin only. The auditor, never the
administrator, can export a bounded window of ledger events, and each export
is itself recorded in the ledger. Signed checkpoints of the ledger's chain
make later tampering detectable. They are stored in the same Supabase project
and signed with a key the platform holds, so they are not proof against the
database owner. An external write-once copy is future work.
Operator checks
- Confirm migration
0015_run_evidence.sqlis applied forward-only before the application commit is promoted. - Run evidence behavior integration only on CI's disposable Supabase stack (or an explicitly confirmed disposable local stack). Never reset the shared local development database.
- Verify
run-evidenceremains private and browser roles cannot insert, update, or delete evidence rows or execute lifecycle RPCs. - Monitor the fixed, path-free
evidence.cleanup_failedandevidence.cleanup_unavailableevents, worker maintenance error counters, waiting-run expiry, provider failure categories,usage_unknown, and run cost reservations without logging input or screenshot content. - For rollback, disable new runtime exposure first. Do not roll migration
0015backward or delete evidence rows/objects as an application rollback shortcut.