Workspace Files
Immutable scanned blobs with a three-step upload, byte-range download, and deferred deletion.
Workspace Files are workspace-owned blobs. Metadata is created first (pending), bytes are written
once, completion verifies size and SHA-256, then a scanner must mark the object ready before
download or attachment. Content is not rewritten in place.
The browser uses /api/files with a signed-in session and workspaceId. API clients use
/api/v1/files with a workspace API key. Both path prefixes share the same handlers.
Upload lifecycle
status | Meaning |
|---|---|
pending | Metadata exists. Only this state accepts PUT .../content. |
pending_scan | Bytes persisted and checksums match. A scan lease is required before ready. |
ready | Scanner returned clean. Download, Command attachments, and presentation inputs require this. |
quarantined | Scanner returned infected. Content cannot be opened. |
deletion_requested | Soft-delete. Blob remains until GC. New references are rejected. |
deleted | GC removed the blob and marked the row. |
Create (POST) with name, mimeType, sizeBytes (0–100 MiB), optional sha256 (64 hex). The
response includes uploadUrl and completeUrl derived from the request path (session URLs keep
?workspaceId=).
Then PUT the raw body to .../{id}/content. Content-Length above 100 MiB is 413 before the
body is stored. Missing body: 400. Only pending files accept content.
Then POST .../{id}/complete with optional sizeBytes / sha256. The handler completes the
upload and, while status is pending_scan, runs scanFile in the same request. HTTP status is
202 with the serialized file (often ready after a clean scan). Replay of the same
Idempotency-Key returns the stored 202 without a second scan.
The UI client polls while any listed file is pending_scan. Only ready files are selectable as
chat attachments.
Authorization
| Surface | Principal | Workspace id |
|---|---|---|
/api/files | Browser session | Query workspaceId, else the session workspace |
/api/v1/files | Workspace API key (Bearer) | Always the key's workspace |
Session access is membership-checked. A workspaceId the user does not belong to is rejected.
v1 missing/invalid bearer: 401 authentication_required. Personal or copilot key: 403
authorization_failed (A workspace API key is required.). A file id in another workspace is
404 (Workspace file {id} was not found.).
Preview capabilities: files.list, files.get, files.read (bounded chunk, default 32 KiB, max
64 KiB), files.delete (confirmation and idempotency required). files.delete also asserts write
readiness.
Storage and scanning
Default blob store is S3 (WORKSPACE_FILE_BLOB_STORE unset or s3). postgres is allowed only
outside NODE_ENV=production. Production requires the private S3 store.
S3 profiles: minio (local), railway (non-local Confluye), aws (legacy, rejected on Confluye
deployments). Minio/railway require endpoint and credentials. Railway rejects
WORKSPACE_FILES_S3_SSE. Object key is the file id (optional WORKSPACE_FILES_S3_PREFIX).
Scanner: WORKSPACE_FILE_SCANNER clamav (default in production) or noop (non-production only).
ClamAV uses CLAMAV_HOST (default 127.0.0.1), CLAMAV_PORT (3310), CLAMAV_TIMEOUT_MS (30s).
Scan lease default is 5 minutes; a stale running scan is marked Scanner lease expired. before
a new claim. Infected → quarantined. Scanner exception → scan row error and the HTTP complete
fails.
encryptionKeyId is stored as null on create. Serialized file objects omit storage keys.
Download
GET .../{id}/content returns the raw stream (not the JSON envelope): 200 or 206 with
accept-ranges: bytes, content-type from metadata, content-disposition attachment (quotes
and newlines in the name become _), etag "sha256-{hex}". Range: bytes={start}-{end} only
(one explicit range). Non-ready files: 423 conflict.
Deletion and GC
DELETE .../{id} is idempotent at the service layer: already deletion_requested or deleted
returns the current row. HTTP 202. Blobs are not removed in that request.
GC (runWorkspaceFileGc): orphans older than 7 days with no live reference (lease null or
unexpired) in pending / pending_scan / ready / quarantined are marked
deletion_requested. Rows already deletion_requested for 30 days have the blob deleted
then status deleted. runDueWorkspaceFileGc runs only when the deployment environment is
production, at most once per UTC day after a complete batch (default batch 100).
Active references (run, message, document, skill, mail, artifact, research_source)
block GC mark/remove. Command job attachments use resourceType: "run" with a default 24h
lease.
Idempotency and retries
Mutations require header Idempotency-Key (withApiIdempotency, required: true). Missing or
invalid key: 400 idempotency_key_invalid. Same key, different body (including file id on
complete/content/delete): 409 idempotency_replay_mismatch. Successful replay sets
Idempotency-Replayed: true and does not re-execute.
Blob put accepts the same key for content uploads. Complete of an already pending_scan or
ready file is a no-op unless completion metadata conflicts (409 size/checksum mismatch).
Degradation
| Condition | Behavior |
|---|---|
| Preview/production primary S3 or ClamAV probe fails | 503; retryable true for files_primary_store_unreachable / files_scanner_unreachable |
| Config missing (S3 parse fail or scanner not ClamAV+host) | Availability blocked (or setup_required when local) |
| Production backup not ready or restore not approved | Capability blocked (files_backup_store_not_configured / files_restore_test_required) |
| Infected scan | quarantined; open content 423 |
| Active scan lease | Cannot scan a file in … / already has an active scan (409) |
| Content before persist | Cannot complete … Upload content has not been persisted. (409) |
