12 Files¶
Status: draft M0. Normative for the module files, the object storage port, the malware scan and
the file endpoints. Terms: 00-glossary.md. Tables:
03-data-model.md (4.7). Actions: 05-actions.md (4.8).
Events and sync: 04-sync-protocol.md. Permissions:
07-permissions.md. Search in files: 14-search.md. Retention:
20-privacy.md. Decisions: ADR 0010 (object storage) and ADR 0033
(malware scan).
All limits, periods and sizes are named configuration values. Their defaults appear only in section 18. External facts carry a reference to the source list (section 19).
1. Goals and limits¶
- No unchecked byte reaches a recipient. The server serves the content, thumbnail and
transcript of a file only when checksum and size match and the malware scan reports
clean(or the scan is explicitly switched off,skipped). - Every download goes through
authorize. Thumbnails and playback requests too. No view permission yields404(B7). - Portable. One port for the object storage, two drivers: file system (default) and S3-compatible with core S3 (B4, R-Objektspeicher, ADR 0010).
- Robust on mobile. Uploads can be retried in parts and resumed after a network drop.
- Privacy. Images that the server shows inline are free of metadata (location, device). No deduplication across files (R-DM-13).
Not a goal: editing files, folders, external share links, link previews from the server (Baseline section 4, SSRF ban), server-side playback in the browser (B5: no chat on the web).
2. States of a file¶
A file has two state fields from 03-data-model.md: upload_state
(bytes complete and verified?) and scan_status (malware scan). In addition there is the state
of the upload session (section 3.1; table file_upload_sessions in 03).
| View | upload_state |
scan_status |
Session state |
Served |
|---|---|---|---|---|
| Upload running | pending |
pending |
open |
no |
| Client done, check running | pending |
pending |
completed |
no; label "being checked" |
| Checksum or size wrong | failed |
pending |
completed |
no; label "Upload failed" |
| Image being cleaned (JPEG, PNG, GIF, WebP; 7.1) | pending |
clean or skipped |
completed |
no; label "being checked" |
| Broken image structure (JPEG or PNG; 7.1) | failed |
clean or skipped |
completed |
no; label "Upload failed" |
| ready | complete |
clean or skipped |
completed |
yes |
| blocked | complete |
infected or error |
completed |
no; label "blocked" |
| canceled or expired | failed |
pending |
aborted or expired |
no |
A file is servable exactly when upload_state = 'complete' and
scan_status IN ('clean','skipped') and deleted_at IS NULL. A file is final
when it is servable or upload_state = 'failed' or scan_status IN
('infected','error'). Section 10 uses "final" for the agent invocation.
For the agent invocation a file counts as final only when, in addition, its transcript_status is
not pending (files/domain.InvocationFinal): a voice file whose server transcript of mode
service is still running (8.2) is served, but it still holds the invocation (section 10;
docs/decisions.d/M3-09.md D-M3-09-14). MP4 audio (the voice format of 8.1, the detected types
audio/mp4 and audio/x-m4a) becomes complete only together with its container values: the
final transaction of files.scan (clean or skipped) leaves it pending and queues
files.thumbnail, as for an image of 7.1, and the container phase of that job sets duration_ms
(when the box reader found it) and upload_state = 'complete' in one transaction; a box error
leaves duration_ms absent and still completes the file, so it stays usable as a normal
attachment (D-M3-09-6). Video and QuickTime keep the order of 7.3.
stateDiagram-v2
state "Upload running" as uploading
state "Check running" as checking
state "ready" as ready
state "blocked" as blocked
state "failed" as failed
state "deleted" as deleted
[*] --> uploading: session created
uploading --> failed: cancellation or expiry
uploading --> checking: complete
checking --> checking: clamd not ready, retry
checking --> failed: size or SHA-256 wrong
state "Image being cleaned" as cleaning
checking --> ready: clean or skipped, no image of 7.1
checking --> cleaning: clean or skipped, JPEG, PNG, GIF or WebP
cleaning --> ready: cleaned original stored
cleaning --> failed: image structure broken
checking --> blocked: infected or error
ready --> deleted: message deleted, file deleted, cleanup
blocked --> deleted: cleanup
failed --> deleted: cleanup
deleted --> [*]
A deleted file is gone for every reader (every read answers 404). The purge files.purge_objects
removes its objects and then its row with the session, the parts and the dependent rows by their
ON DELETE CASCADE (R-DM-46, section 12; M3-08).
Every change of upload_state, scan_status, thumbnails or transcript writes the
durable event file.updated (receive action file.read, 05-actions.md
section 6). Content never appears in the event.
Before attachment the event goes only to the uploader: it carries audience_principal_id =
uploaded_by in the scope of the scope channel, because the scope alone would hand the file name
to every reader of the channel, while before attachment only the uploader reads the file (05 2.2;
docs/DECISIONS.md D-M3-03-16). An attached file is written without audience in the scope
{space_id, channel_id, thread_id} of its message, with channel_id and thread_id from the
copies in message_attachments (R-DM-43), and its payload has no scope_channel_id (section 10).
Creating and completing an upload write no event: neither upload_state nor scan_status changes.
One write appends file.updated for every such change, and the same write publishes the in-tx
event files.FileFinalized exactly when the file becomes final (not final before, final after):
the scan outcome, the abort of an upload and the image transactions of 7.1 and 7.3, so every
finalization publishes once and in the transaction that stores the final state (section 10;
docs/decisions.d/M3-07.md D-M3-07-6).
"Final" in that write means final for the invocation: FileFinalized is also published when a
pending transcript of a final file ends (done or failed), and setting pending on a final file
publishes nothing (docs/decisions.d/M3-09.md D-M3-09-14).
3. Upload flow¶
3.1 Creating an upload session¶
The client declares the file upfront. The server checks everything that can be checked without bytes and rejects early, before bandwidth is used.
Request (file.upload on the channel; Idempotency-Key is the optional parameter of openapi,
a repeat answers the stored response):
| Field | Required | Rule |
|---|---|---|
name |
yes | is sanitized (section 6.2) and stored as original_name |
size_bytes |
yes | 1 ..= file.max_upload_bytes; otherwise 413 payload_too_large |
sha256 |
yes | SHA-256 of the whole file as lowercase hex (^[0-9a-f]{64}$); the verify pass checks it |
declared_mime_type |
no | hint only; stored without parameters in lower case in declared_mime_type; the server detects the type itself |
purpose |
no | default attachment; further values in 3.7 |
poster |
no | video only: small JPEG or PNG as thumbnail, at most file.poster_max_bytes |
The server checks in this order:
authorize(principal, file.upload, channel). No read permission:404. Read permission, butwriteoruploadmissing:403.- Size against
file.max_upload_bytes. - Early type check (section 6.1): the name is cleaned (6.2); a denied declared type or an
extension of the cleaned name that belongs to a denied type gives
415 unsupported_media_typewithreasonfile_type_denied. It needs no database, so it comes before the counts (docs/DECISIONS.md D-M3-03-4, D-M3-03-5). - Quota: the sum of
size_bytesof the principal's files that are neither deleted nor failed plus the new size is at mostfile.quota_bytes_per_principal(0 = off). Otherwise409 conflictwithreasonquota_exceeded. Failed files count as deleted: their objects go at once (section 12), so an aborted upload frees its quota (D-M3-03-6). - Open sessions of the principal below
file.upload_max_open_sessions. Otherwise429 rate_limitedwithRetry-Afteruntil the earliest expiry of the open sessions, in whole seconds and at least one (D-M3-03-8). - File system driver only: free space after the upload at least
file.fs.min_free_bytes. Otherwise503 unavailablewithreasonstorage_fulland a warning to the status page.
Steps 4 to 6 run under a transaction-level advisory lock of the principal, so two parallel announcements cannot both pass a limit (D-M3-03-7).
Then it creates in one transaction: a row in files (upload_state = 'pending',
scan_status = 'pending', mime_type = 'application/octet-stream' until detection,
scope_channel_id = channel, sha256 and size_bytes = declared values) and a row in
file_upload_sessions (03). The response names
file_id, mode, part_size_bytes, part_count, expires_at and, for mode = presigned,
the target URLs per part.
part_size_bytes is file.upload_part_bytes; part_count = the rounded-up size_bytes /
part_size_bytes. The last part may be smaller. The default lies above the smallest
S3 part size so that the same part plan applies to both drivers (S3: parts at least 5 MiB
except the last, at most 10,000 parts [Q1, verified]). At startup the server checks that
file.max_upload_bytes / file.upload_part_bytes lies below this part limit.
3.2 Transferring parts (mode direct, default)¶
PUT /api/v1/uploads/{file_id}/parts/{n} with the raw bytes of the part and the header
Content-Digest: sha-256=:<base64>: per RFC 9530 [Q2, verified].
- Only the principal of the session may send parts; otherwise
404. - The server checks length (
part_size_bytes, for the last part the remainder) and digest while reading. Mismatch:400 validation_failedwitherrors[].codepart_digest_mismatch, nothing is stored. - A part is idempotent: the same number with the same digest yields
200without new work; the same number with a different digest yields409 conflictwithreasonpart_conflict. - Parts may arrive in parallel and in any order. The client chooses the parallelism.
GET /api/v1/uploads/{file_id}returns the parts already confirmed. After an interruption a client resumes from there.
The S3 driver passes each part through as a multipart part (no buffering in the server). The
multipart upload is created at the first part, outside any transaction; its ID goes to
file_upload_sessions.storage_upload_id, and a request that loses this race aborts its own
upload and uses the stored one. The ETag of an S3 part is the one the service returns, kept in
file_upload_parts.etag. The file system driver writes part n as the object
uploads/<file_id>/<n> under file.fs.root; its ETag is the lowercase hex SHA-256 of the part in
double quotes, derived from file_upload_parts.sha256, whose etag stays NULL
(D-M3-03-2, D-M3-03-3). A part is never written again once it is confirmed; an object store call
never runs inside a database transaction (02 8.5).
3.3 Presigned PUT (mode presigned, optional)¶
Only with the S3 driver and file.s3.presigned_upload = true. The session response contains,
per part, a presigned URL for the multipart part, valid for file.s3.presign_ttl. The client
uploads directly to the object storage and reports the ETags of the parts on completion. On
completion the server checks part count and total size (HEAD), and in the verification pass
(3.4) the SHA-256.
The mode stays off until two things are proven:
- The presigned URL binds the part length, so that a client cannot store more bytes than
declared. Integration test against every supported S3 service. Result of M3-02
(
TestPresignedPartBindsLength, docs/DECISIONS.md M3-02/9): proven for SeaweedFS at the version the Compose stack pins (BOTHY_SEAWEEDFS_VERSION). The S3 driver signscontent-lengthinto every part URL; SeaweedFS accepts a plainPUTof exactly the signed length and refuses one byte more, one byte less, chunked transfer encoding and a request after the validity, each without storing a part. Other S3 services are not proven. - The clients reach the S3 endpoint. For instances in private networks this is often not so (open, not checked).
Reason for "optional": the direct path is enough for the limit file.max_upload_bytes and
needs no public reachability of the storage.
3.4 Completion and verification pass¶
POST /api/v1/uploads/{file_id}/complete:
- All parts present (otherwise
409 conflictwithreasonupload_incompleteand the missing numbers indetail). partsof the request is checked against the confirmed parts: a duplicate or out-of-range number gives400 validation_failed, a listedetagthat differs from the confirmed one409 conflictwithreasonpart_conflict; the list may name fewer parts than exist (D-M3-03-12). No object store call runs here: the S3 driver'sCompleteMultipartUploadruns in the verify pass, because a keyed request runs inside a transaction (02 8.4, 8.5; D-M3-03-2).- Session
state = 'completed', jobfiles.scaninto the queue (River, M3-04), response200with the file object. A completed session answers200with the file again. From now on the client may attach the file to a message.
The job files.scan reads the bytes exactly once (on S3 after CompleteMultipartUpload)
and splits the stream into:
- SHA-256 and byte counter. If they differ from the declaration:
upload_state = 'failed', reasonchecksum_mismatchorsize_mismatchrespectively in the session, delete the object,file.updated. - Type detection over the first
file.sniff_bytes(section 6.1). - clamd
INSTREAM(section 5). - File system only: joining the parts into the target object (write to a temporary file,
fsync,rename). After that the parts are deleted.
A single read pass keeps the cost low for large files and works the same way for both modes. The checksum per part (3.2) catches transfer errors early; the total checksum here catches errors during joining and in presigned mode.
If all is well: upload_state = 'complete', mime_type = detected type, scan_status per
section 5.2, scanned_at, scan_engine, event file.updated. Exception for images (M3-06,
docs/DECISIONS.md D-M3-06-1): when the detected type is image/jpeg, image/png, image/apng,
image/gif or image/webp and the verdict is clean or skipped, the same transaction writes
scan_status, scanned_at, scan_engine and mime_type but leaves upload_state = 'pending'
and inserts the job files.thumbnail; the image becomes complete only in the transaction of
that job that stores its cleaned original (section 7.1), so the original with its location data
is never servable and never "final" for a held agent invocation (section 10). MP4 and
QuickTime files (video/mp4, video/quicktime, video/x-m4v, audio/mp4, audio/x-m4a)
become complete as before and get files.thumbnail for their display size and duration
(section 7.3). Blocked and failed files never get the job.
Rules of the pass (M3-04, docs/DECISIONS.md D-M3-04-1 to D-M3-04-15): the job reads
object_key when it exists (after an earlier attempt or, on S3, after the pass ran
CompleteMultipartUpload), otherwise the parts in order, and writes the joined object from the
same stream; the source is limited to the declared size plus one byte. Size, SHA-256 and a
detected type listed in file.denied_mime_types decide first (failed with size_mismatch,
checksum_mismatch or file_type_denied), also when the scan failed transiently; a malware
finding is audited for such a file as well. Then 5.2 applies; the reasons of blocked files are
malware_detected, scan_limits_exceeded, scan_stream_limit_exceeded and
scan_engine_error in the session's failure_reason. scan_engine = 'clamd' and scanned_at
are set when clamd decided the file, NULL for skipped and failed. The result is written in one
transaction that locks the session and then the file, writes file.updated without actor and
publishes files.FileFinalized for a final file; objects of failed and blocked files and the
parts are deleted after the commit. A transient failure inserts the next files.scan with a
delay that doubles from file.scan.health_interval up to file.scan.retry_backoff_cap, without
an attempt limit, and changes nothing else. If the file is servable or an
image being cleaned, the processing of sections 7 and 8 follows in the job files.thumbnail
(02 12.3 "Job catalogue"). For an attachment of a type the text extraction supports, the
transaction that makes the file deliverable also creates its row in file_text_extracts and
inserts the job files.extract_text (14-search.md section 5).
3.5 Sequence (direct upload with scan)¶
sequenceDiagram
autonumber
participant C as Client (Alex)
participant API as API process
participant OS as Object storage
participant DB as PostgreSQL
participant W as Worker
participant AV as clamd
C->>API: POST /api/v1/channels/{id}/uploads (name, size, SHA-256, type)
API->>API: authorize(Alex, file.upload, channel), limits
API->>DB: files pending, file_upload_sessions open
API-->>C: 201 file_id, part_size_bytes, part_count
loop per part, parallel possible
C->>API: PUT /api/v1/uploads/{file_id}/parts/{n} (Content-Digest)
API->>API: check length and digest
API->>OS: write part
API-->>C: 200 part_number, etag
end
C->>API: POST /api/v1/uploads/{file_id}/complete
API->>DB: session completed, job files.scan
API-->>C: 200 file (pending, being checked)
W->>OS: read bytes, one pass
W->>W: SHA-256, size, type detection
W->>AV: INSTREAM in chunks
AV-->>W: stream: OK
W->>DB: scan_status clean, image stays pending, job files.thumbnail, file.updated
W->>OS: read orig-1 twice through the filter, write orig-2
W->>DB: object_key orig-2, sha256, size, width, height, upload_state complete, file.updated
W->>OS: delete orig-1, read orig-2, write small, medium, large
W->>DB: file_thumbnails, file.updated
3.6 Error cases¶
| Case | Response or consequence |
|---|---|
| No read permission on the channel | 404 |
write or upload missing |
403 |
Size above file.max_upload_bytes |
413 payload_too_large |
| Quota exhausted | 409 conflict with reason quota_exceeded |
| Too many open sessions | 429 with Retry-After |
| Storage almost full (file system) | 503 unavailable with reason storage_full |
| Part with wrong length or digest | 400 validation_failed with errors[].code part_digest_mismatch |
| Part number already used with different content | 409 conflict with reason part_conflict |
complete with missing parts |
409 conflict with reason upload_incomplete |
Session expired (file.upload_session_ttl) |
410 gone; cleanup deletes the parts and aborts the multipart upload |
| Permissions revoked during the upload | next request 404; the session is aborted |
| Total checksum or size wrong | upload_state = 'failed', file.updated |
3.7 Purpose of a file¶
Not every file belongs to a channel. 05-actions.md section 2.2 defines the
path per purpose. The column files.purpose (03) carries its own permission rule per purpose:
purpose |
Upload action | scope_channel_id |
Read |
|---|---|---|---|
attachment |
file.upload |
required | file.read via the channel (before attaching only the uploader) |
avatar |
self.profile.update or agent.update |
NULL | every active, authenticated principal (auth) |
branding |
instance.branding.update |
NULL | public like instance.discovery.read (logo on the login page) |
gdpr_export |
server only | NULL | gdpr.export.download |
All purposes go through the same check and the same scan.
Avatar and logo (M3-10). setMyAvatar and uploadBrandingLogo take the image as the one
multipart part file (ImageUpload in openapi.yaml); authorize runs before the first byte of
the body is read. The server then stages the file outside any transaction, in this order, and
stores nothing when a check fails:
- The first part is named
file, else400 validation_failed(pointer/file, coderequired). - The declared type is
image/jpeg,image/pngorimage/webp, else415 unsupported_media_type; a declared type infile.denied_mime_typesgives415withreasonfile_type_denied(6.1, first step). - The part is read into memory up to the smaller of
file.max_upload_bytesandfile.upload_part_bytes, counted and hashed with SHA-256; more bytes give413 payload_too_largewhosedetailnames the key that set the cap, no byte gives400 validation_failed. A further part afterfilegives400 validation_failed. - The limits of 3.1 for open sessions and free space apply; the quota is not checked, because an avatar or a logo replaces the previous one.
- Rows before bytes: a
filesrow with the purpose,scope_channel_idNULL and the fixedoriginal_nameavatarorlogo(the client's file name is ignored, because every signed-in principal may read these files), a session in modedirectwith the counted size and hash as declared values, then the one part through the part logic of 3.2.
One unit of work per operation then locks the anchor row (the principal row, or the setting row
branding.logo_file_id), completes the session and inserts files.scan (3.4), links the new file
and marks the previous file of the same purpose deleted (deleted_at; for an avatar, only a file of the same uploader). The file is
linked before it is deliverable: until then readers get 409 file_not_ready with Retry-After
(9.3) and getBrandingLogo answers 404, so the login screen shows the product mark. The pass
of 3.4 and 7 also applies the purpose rule: an avatar or logo is accepted only as filtered JPEG,
filtered PNG or converted still WebP; any other detected type, an animated WebP and a still WebP
above file.image.max_pixels end upload_state = 'failed' with failure_reason
file_type_denied before they can become deliverable, because only originals the server
filtered or converted are free of metadata (7.1). A JPEG or PNG above file.image.max_pixels is
filtered, stays deliverable and gets no thumbnails (7.2). Every state change of such a file
writes file.updated as a personal event of the uploader (audience_principal_id =
uploaded_by, empty scope), because only the uploader waits for the result; other readers learn
of the new file through user.updated or instance.updated. getBrandingLogo serves only the
variant medium (7.3) of a deliverable branding file, re-encoded by the server, with
Cache-Control: no-store and without Range; a missing, pending, blocked, failed or deleted logo
and one without that variant give the same 404. Avatars of agents have no operation in
openapi.yaml yet, although the table names agent.update (spec gap). Rulings D-M3-10-1 to
D-M3-10-23 in docs/decisions.d/M3-10.md.
4. Object storage¶
4.1 Port¶
The module files talks to the storage only through the port ObjectStore. It contains exactly
these operations:
| Operation | Purpose |
|---|---|
Put(key, reader, size) |
write a whole object |
Get(key, range) |
read, optionally a byte range |
Head(key) |
size and existence |
Delete(key) |
delete one object; a missing object is not an error |
CreateMultipart(key), UploadPart(key, uploadID, n, reader, size), CompleteMultipart(key, uploadID, parts), AbortMultipart(key, uploadID) |
multipart uploads |
PresignUploadPart(key, uploadID, n, size, ttl) |
S3 only, only for mode presigned; the signature binds the part length size |
Not in the port: listing, copying, batch deletion, versioning, bucket policies, lifecycle
and encryption headers. The server knows every key from the database and needs no
listing. Batch deletion (DeleteObjects) is left out on purpose: AWS SDKs enforce a
checksum there on which several S3-compatible services fail [Q3, verified; Q4, search
summary only].
Port contract (M3-01, internal/files/app, docs/DECISIONS.md M3-01/1 to M3-01/6). The port is an
outgoing port of the application layer; no other module sees it. Every operation takes a context
first; implementations are safe for concurrent use, and nothing in the port reads a whole object
into memory (Get streams, the caller closes the reader).
- Keys: one grammar for all drivers (
domain.ValidateKey): one or more segments of[a-z0-9-], joined by single/, without a leading or trailing/. It refuses.,.., absolute paths, empty segments, backslashes, upper case, percent signs, spaces, control and non-ASCII characters. Every operation checks the key first and returnsErrInvalidKeybefore any storage call. The key builders of 4.4 take only the file ID, a number and the closed typeVariant. Getreads the whole object or exactly one byte range{Offset, Length}(9.3).Offset < 0,Length < 1orOffsetat or beyond the object size giveErrInvalidRange; a range whose end lies beyond the object ends at the last byte.- Errors:
ErrNotFound(GetorHeadof a missing object),ErrInvalidRange,ErrSizeMismatch(size < 0or a body of another length; nothing is stored),ErrUploadNotFound(an upload that was never created, was aborted or was completed),ErrInvalidPart(part number below 1; inCompleteMultipartan empty list, numbers that are not strictly ascending, a number without an uploaded part or an ETag that does not match; the upload stays open),ErrNotSupported(an operation the driver does not offer).Deleteof a missing object andAbortMultipartof an unknown, aborted or completed upload return no error (section 12). Every other error is a storage failure that callers answer with503 unavailable(02-architecture.md 14.4); errors name the operation and the key and never contain object bytes. The port never panics on a failing storage. Puton an existing key replaces it atomically. The drivers do not enforce "Objects are never overwritten"; the module does, through the key schema and the uniquefiles.object_key.PresignUploadParttakes the size of the part before the validity (M3-02, docs/DECISIONS.md M3-02/9), because no driver can bind a length it does not know.n < 1givesErrInvalidPart,size < 1givesErrSizeMismatch, both on every driver; the file system driver then answersErrNotSupported. The URL is a credential for its validity: callers hand it to the client only and never log it.
4.2 File system driver (default)¶
- Root
file.fs.root, a dedicated directory for Bothy only. Directories0700, files0600. - Paths arise only from the key schema (4.4). The driver rejects keys with
.., absolute paths or characters outside[a-z0-9/-]and follows no symbolic links. - Writing: temporary file in the same file system,
fsync,rename,fsyncon the directory. A reader never sees half an object. Getwith a range viaReadAt. No buffering in the server.- Encryption at rest is a task for operations (disk encryption, 16-operations.md).
File system rules (M3-01, internal/files/adapters/fs, docs/DECISIONS.md M3-01/5 to M3-01/11, M3-01/15 and M3-01/17 to M3-01/19):
- Start check:
file.fs.rootis an absolute, clean path of an existing directory (not a symbolic link), owned by the effective user of the process, with permission bits exactly0700. The server creates<root>/.tmp(mode0700) when missing and never creates the root itself; a missing root means a wrong mount; an existing.tmpmust be a directory of the same user with mode0700. Errors namefile.fs.root, not the path. - Keys map to paths below the root. The driver works through a handle of the root directory
(
os.Root), so no operation leaves it, and it follows no symbolic link at any level: every existing path component is checked withlstatbefore an operation and before a rename; a read checks the path again when the opened file is not the one it checked, becauseos.Rootfollows a link inside the root even withO_NOFOLLOW. A link found is a storage failure, notErrNotFound. A directory or another non-regular entry at a key counts as missing. - Writes go to a temporary file with mode
0600in<root>/.tmp, which no key can address (keys contain no dot). Exactlysizebytes are copied with a bounded buffer; thenfsyncof the file, missing directories with mode0700(each synced in its parent),rename,fsyncof the directory. A platform that refuses to sync a directory as "not supported" counts as success. Every failed write removes its temporary file. Deleteremoves the file, syncs the directory and removes the directory of the file (f/<aa>/<bb>/<file_id>oruploads/<file_id>) if it is empty now; the levelsf/<aa>andf/<aa>/<bb>stay. A writer whose directory a concurrentDeleteremoved creates it again and renames again.- Multipart:
CreateMultipartaccepts only original keys, createsuploads/<file_id>/, is idempotent and returns the upload ID"".UploadPartwritesuploads/<file_id>/<n>like an object and returns the ETag"<sha256 hex>"of the part bytes (the form of 9.3); a part with the same number is replaced atomically. While the upload is open, parts are regular objects under their keys.CompleteMultipartwrites the listed parts in order into one temporary file, reads each part exactly once and checks it against its ETag, renames to the key and removesuploads/<file_id>/with all parts; a failure of that removal does not fail the completion;Deleteof an original key also removesuploads/<file_id>/, so parts left behind go with the file. The list shape is checked before any file system call.AbortMultipartremoves that directory. Deleting the last part of an open upload withDeleteremovesuploads/<file_id>/and so ends the upload. PresignUploadPartgivesErrInvalidPartfor a part number below 1 and otherwiseErrNotSupported. Several processes may share one root (rolesapiandworkeron one volume), because every change is a rename or an unlink; the driver takes no lock. Every write ends with a rename or the removal of its temporary file, and no write outlives an upload session, so a temporary file older thanfile.upload_session_ttlbelongs to a crashed process: the jobfiles.cleanup_pendingremoves the entries of<root>/.tmpwhose modification time is older than that (section 12). The sweep reads only.tmp, never the key space (4.1), and removes a symbolic link as an entry without following it (M3-08).
4.3 S3 driver (core S3 only)¶
- AWS SDK for Go v2 with its own endpoint (
file.s3.endpoint), region, bucket and path style (file.s3.use_path_style). Credentials only as a reference to the secret provider. - Checksum mode
when_requiredfor request and response (file.s3.checksum_mode). Otherwise the SDKs compute additional checksums by default that not every S3 service understands [Q3, verified]. Our own SHA-256 from 3.4 replaces this check. - Recommended self-hosted service: SeaweedFS (Apache-2.0) [Q5, verified]. MinIO is not recommended because the repository is archived and no more binaries are released [Q6, verified].
- The bucket is private. The instance operator sets up server-side encryption and the lifecycle rule "abort incomplete multipart uploads" on the storage; the server does not rely on them and aborts expired uploads itself (section 12).
S3 driver rules (M3-02, internal/files/adapters/s3, docs/DECISIONS.md M3-02/1 to M3-02/14):
- Core S3 only: the driver holds a Go interface with exactly
PutObject,GetObject,HeadObject,DeleteObject,CreateMultipartUpload,UploadPart,CompleteMultipartUpload,AbortMultipartUploadand a presign interface with onlyPresignUploadPart. Only this package and the audit checkpoint sinkinternal/audit/adapters/s3sink(M7-22) and the object mirror of the backup serviceinternal/status/adapters/s3mirror(M7-02) import the AWS SDK (depguard inmake lint,TestS3SDKOnlyInAdapter); forbidigo forbidsDeleteObjectsandPresignGetObject. No request of the files driver sets server-side encryption, ACL, tagging, object lock, storage class or conditional headers. file.s3.endpointis required; the driver never uses the endpoint resolution, the default configuration or the credential chain of the SDK (environment, shared files, instance metadata, whose address SEC-NET-3 always blocks).- Every request goes through the egress client (19-security.md 8) with
purpose
objectstore; its host policy allows exactly the request host and port (the endpoint with path style,<bucket>.<endpoint host>with virtual-hosted style). Anhttpendpoint or one on a private address needs anegress.allowed_hostsentry withallow_private. For anhttpendpointbothy config checkfails without it (cross-checkfile_s3); a private address behind anhttpsendpoint is refused by the egress client when it connects. TLS is always verified. Redirects are never followed; the time limit of a request ishttp.transfer_timeout, the largest response bodyfile.max_upload_bytes. - No retries in the SDK (02-architecture.md 14.3): a failed call is one
request, and the caller sees a storage failure (
503 unavailable); jobs retry through River. PutandUploadPartstream the caller's reader without a buffer and sign it asUNSIGNED-PAYLOAD, because the reader is not seekable and the endpoint may be plainhttp; the part digest (3.2) and the file SHA-256 of 3.4 check the content. A body of another length givesErrSizeMismatchand stores nothing: the driver holds back the last byte until the reader reports its end.- Access keys come only from
file.s3.access_key_refandfile.s3.secret_key_refthrough the secret provider (16-operations.md 5.2), read only withfile.storage.driver = s3. A key that cannot be read stops the start; the driver asks the provider again on every refresh of the SDK credential cache, so a rotation works without restart aftersecrets.cache_ttl. - The object name is
<file.s3.prefix>/<key>with a prefix, else the key; key and prefix follow the key grammar of 4.1, checked before any request. - Errors keep the contract of 4.1 and read
files/s3: <operation> <key>: <detail>; they never contain a request URL, a query, a presigned URL, the endpoint or credentials. AHEADanswer has no body, soHeadon a missing bucket reads asErrNotFound;GetreportsNoSuchBucketas a storage failure.
The second package that may import the AWS SDK (M7-22, docs/DECISIONS.md M7-22/4) is the object
store sink of the audit checkpoints, internal/audit/adapters/s3sink
(19-security.md section 16, audit.checkpoint_sink = s3://...). It holds a
Go interface with only PutObject (with If-None-Match: *), GetObject, ListObjectVersions
and GetObjectLockConfiguration (TestAPIMethodSet), and follows the egress and credential rules
above with its own keys (audit.checkpoint_s3_*, purpose audit_checkpoint, the limits
egress.timeout and egress.max_response_bytes). The rules of this section stay unchanged for
the files driver.
4.4 Key schema¶
f/<aa>/<bb>/<file_id>/orig-<n> original, n = revision (1 = as uploaded)
f/<aa>/<bb>/<file_id>/small thumbnail variants from file_thumbnails
f/<aa>/<bb>/<file_id>/medium
f/<aa>/<bb>/<file_id>/large
f/<aa>/<bb>/<file_id>/poster
uploads/<file_id>/<n> file system only: parts during the upload
<aa> and <bb> are the last four hex characters of the file_id. With UUIDv7 these are
random bits; they spread files evenly across directories. The key contains no names, no
channel or space IDs and no file name (03-data-model.md,
files.object_key). If processing changes the original (section 7), orig-2 is created;
files.object_key points to the new object in the same transaction, after that orig-1 is
deleted. Objects are never overwritten.
Optionally file.s3.prefix before all keys, so that one bucket can hold several environments.
5. Malware scan¶
Decision and alternatives: ADR 0033. ClamAV runs as a separate service; the server talks to
clamd over the protocol, never through an embedded library. The port is called Scanner
(Scan(ctx, reader) -> Verdict, Ping(ctx)) so that other engines can be connected later.
5.1 Flow¶
- Connection to
file.scan.clamd_address, only a Unix socket (19 SEC-TLS-5): clamd checks requests neither for origin nor for authentication [Q7, verified], so the server accepts onlyunix://with an absolute path and no TCP address. The Compose stack shares the socket with the worker through the volumeclamav-socket(infra/compose/README.md). - Command
zINSTREAM\0(NUL framing is the recommended form) [Q7, verified]. - Data in chunks: per chunk 4 bytes of length in network byte order, then the bytes; end
with a chunk of length 0 [Q7, verified]. Chunk size
file.scan.chunk_bytes. - Read the response until clamd closes the connection:
stream: OK,stream: <signature> FOUNDor a line withERROR[Q7, verified]. - Time limit per file
file.scan.timeout. At mostfile.scan.max_concurrentscans per worker process.
A small client of our own is enough; the protocol is small, and the well-known Go library
go-clamd has had no release since 2017 [Q8, verified].
5.2 Result mapping¶
| clamd response | scan_status |
Consequence |
|---|---|---|
stream: OK |
clean |
processing (sections 7, 8), file.updated |
<signature> FOUND, signature starts with Heuristics.Limits.Exceeded |
error |
reason scan_limits_exceeded; not servable |
<signature> FOUND, otherwise |
infected |
delete the objects immediately, the row stays; audit (below) |
INSTREAM size limit exceeded or another ERROR |
error |
reason from the response; not servable |
| Time limit, connection error | stays pending |
retry (5.3) |
On infected an audit entry is written with action = 'file.upload', decision = 'deny',
deny_reason = 'malware_detected' and, in details, the signature, the SHA-256 and the size,
never content (05: file.upload with audit on malware detection). The uploader and the owner see the reason; everyone else sees only "blocked".
5.3 When ClamAV is missing or not ready¶
file.scan.mode has two values:
required(default): fail closed. If clamd is not reachable or does not answerPINGwithPONG[Q7, verified], the file stayspending. The job retries with backoff up tofile.scan.retry_backoff_cap, without a cap on the number of attempts, as long as the file is not deleted. Nothing is served unchecked.off: no scan,scan_status = 'skipped'. Meant for development and for installations without the memory that ClamAV needs (section 5.4). The admin console and the status page permanently show "Virus scan off".
The worker checks clamd every file.scan.health_interval with PING. The result feeds the
status page component "Virus scan" (status_components.health_source = 'clamd'). If the
oldest file has been in pending longer than file.scan.pending_alert_after, the component
becomes degraded and an alert fires (17-observability.md). At startup
clamd takes a long time to load signatures; official images have a startup time limit for
this, default 1800 s [Q9, verified]. Uploads during this time simply wait.
If file.scan.mode changes from off to required, skipped files stay skipped. An
admin can have them rechecked with the maintenance command bothy files rescan --skipped
(server command, not an endpoint).
5.4 Required settings for clamd¶
The Compose stack (16-operations.md, infra/compose) configures clamd like this:
| clamd option | Value | Reason |
|---|---|---|
AlertExceedsMax |
yes |
The default is no: files above MaxFileSize, MaxScanSize or MaxRecursion are then neither checked nor reported [Q10, verified]. With yes they appear as Heuristics.Limits.Exceeded... and become error (5.2). |
StreamMaxLength |
at least file.max_upload_bytes |
Default 100M, 1M = 1048576 bytes [Q10, verified]; above that clamd aborts the stream. |
MaxFileSize |
at least file.max_upload_bytes |
Default 100M [Q10, verified]; otherwise the file itself would already be skipped. |
MaxScanSize, MaxRecursion, MaxFiles |
defaults | apply to unpacked content; with AlertExceedsMax exceeding them becomes error. |
AlertEncrypted |
no (default) |
Encrypted archives and PDFs are common in everyday work. They are therefore not blocked, but their content is not checked either. The residual risk is in the threat model (19-security.md). |
The server cannot learn the clamd settings through the protocol. That is why
file.scan.clamd_stream_max_bytes exists as a mirror of StreamMaxLength. The server does not
start if file.scan.mode = required and file.max_upload_bytes is greater than this value.
Image: clamav/clamav-debian exists for amd64 and arm64; clamav/clamav:stable only for amd64
[Q11, verified]. According to the project, ClamAV needs at least 3 GiB, better 4 GiB of memory
[Q9, verified]. ClamAV is licensed under GPL-2.0 [Q12, verified]; the separation via socket and
a separate container is separate communication according to the GPL FAQ [Q13, verified]; the
legal assessment remains the instance operator's responsibility (no legal advice).
6. Type detection and file names¶
6.1 Type¶
- The server detects the type from the bytes with
github.com/gabriel-vasile/mimetype(MIT, without cgo) [Q14, verified]. For this it readsfile.sniff_bytes; Office formats need more than the header [Q14, verified]. declared_mime_typestays for diagnosis only. The file extension in the name changes nothing.- If the detected type differs from the declared one, the detected one applies. This is not an error.
file.denied_mime_types(default empty) blocks types that the instance operator does not want. The check runs twice: upfront against the declared type and the extension (early rejection,415 unsupported_media_typewithreasonfile_type_denied), then against the detected type (upload_state = 'failed'). Default empty because developer channels legitimately share executables and archives, and the scan as well as serving as a download (9.3) carry the protection.
6.2 File names¶
Sanitizing on declaration, result in original_name:
- Unicode NFC.
- Only the last path segment;
/and\are removed [Q15, verified]. - Remove control characters, bidi control characters (for example U+202E, which can disguise "exe" as "pdf") and zero-width characters; trim whitespace at the start and end [Q15, verified].
.and..alone are invalid [Q15, verified]. An empty remainder yieldsfile. A name that is.or..after steps 1 to 3 also becomesfile: the name is only a label, and a400for an already cleaned name would give the client nothing to correct (D-M3-03-9). Names are cleaned, never rejected.- Length at most
file.name_max_bytesUTF-8 bytes; truncation happens before the extension.
7. Images: metadata and thumbnails¶
7.1 Rule¶
Images are free of metadata as soon as they are stored (Ruling L16, ADR 0047). Two paths:
- Original, lossless. For JPEG and PNG the server removes the metadata from the original
when the upload completes, without re-encoding the image data. It works with an
allow list: only segments or chunks that are needed for decoding or for correct color and
orientation stay; everything else is dropped (reason for the list instead of a deny list:
R-SE-03 in 19-security.md). If the filter fails on a file that was
detected as JPEG or PNG (damaged structure), the file is not stored
(
upload_state = 'failed'), because it could carry location data unchanged. - Thumbnails, re-encoded. All variants from 7.3 are created by decoding and re-encoding in pure Go (Baseline R-Objektspeicher) with the orientation applied. They carry no metadata except the unchanged color profile of the original.
| Type | What stays in the original | What is dropped |
|---|---|---|
image/jpeg |
markers for header, tables, frame and image data (SOI, DQT, DHT, DRI, SOFn, SOS with data, EOI), APP0 (JFIF), APP2 only with the identifier ICC_PROFILE (all parts of a multi-part profile), APP14 (Adobe color transform) and a newly written APP1 with only the EXIF tag "Orientation" |
all other APPn (EXIF including the embedded thumbnail, XMP, IPTC, Multi-Picture Format), COM and every byte after the first EOI (appended images, for example gain maps) |
image/png |
critical chunks (IHDR, PLTE, IDAT, IEND) as well as tRNS, cHRM, gAMA, iCCP, sBIT, sRGB, cICP, mDCV, cLLI, bKGD, pHYs, acTL, fcTL, fdAT |
tEXt, zTXt, iTXt, eXIf, tIME, hIST, sPLT and unknown chunks |
image/gif |
is re-encoded (lossless, palette); comments and application blocks except the loop setting are dropped | |
image/webp (still) |
is decoded (x/image/webp can only read [Q18, verified]) and stored as PNG (with alpha) or JPEG (without alpha, file.image.jpeg_quality); with libvips re-encoded as WebP |
|
image/webp (animated), image/heic, image/heif, image/avif, image/tiff, others |
no inline image: stored unchanged as a file, shown as a download card; with libvips (file.image.libvips_enabled, own build with cgo; libvips LGPL-2.1, govips MIT [Q17, verified]) converted to JPEG, then like JPEG |
|
image/svg+xml |
never inline, never a thumbnail, always a download |
Evidence: APPn segments (X'FFE0' to X'FFEF') and COM (X'FFFE') are reserved for the
application and are evaluated during decoding only as far as they do not disturb it (ITU-T T.81,
Annex B, https://www.w3.org/Graphics/JPEG/itu-t81.pdf, retrieved 2026-10-10 [verified]); their
removal therefore does not change the image data [derived]. PNG lists tEXt, zTXt and iTXt
as text chunks and eXIf as an Exif profile with data such as exposure and orientation
(https://www.w3.org/TR/png-3/, retrieved 2026-10-10 [verified]).
The sanitized original becomes orig-2 (4.4); sha256, size_bytes, mime_type, width and
height then describe the stored object (03-data-model.md:
"over the stored bytes"). The declared checksum stays in the upload session
(declared_sha256). The app shows the variant medium in the chat and large in full screen;
the original is available as a download in full quality.
Limit of this promise: the server does not remove metadata inside other files (PDF author, Office properties, photos in a ZIP). The app converts photos from the media library to JPEG before the upload (15-client-architecture.md); HEIC then reaches the server only as a deliberately sent file.
Orientation and ICC profile stay because photos from current phones often carry a large color profile and are stored rotated; without both they would look pale or tilted. When re-encoding the thumbnails the server applies the orientation itself because decoding discards it [Q16, derived].
Rulings of M3-06 (docs/DECISIONS.md D-M3-06):
- Servable only when cleaned (D-M3-06-1). A JPEG, PNG (also
image/apng), GIF or WebP staysupload_state = 'pending'after the scan; the jobfiles.thumbnailwrites the cleaned original and setscompletein one transaction. Until then delivery answers409 file_not_readyand an agent invocation waits (section 10). - JPEG (D-M3-06-2). Kept are the markers T.81 needs to decode (
SOI,DQT,DHT,DAC,DRI,DNL,DHP,EXP,SOFn,SOSwith its data andRST0toRST7,EOI), the firstAPP0JFIFrewritten without its embedded thumbnail, everyAPP2ICC_PROFILEpart,APP14Adobe, and one newAPP1with only "Orientation" (written only for the values 2 to 8, D-M3-06-3). Output order:SOI,APP0,APP1, then the other kept segments in input order. - PNG (D-M3-06-4). A CRC mismatch, a chunk beyond the input,
IHDRnot first or a missingIENDis a broken structure (failed, reasonimage_malformed, D-M3-06-7); bytes afterIENDare dropped. The orientation of aneXIfchunk is not applied, so original and thumbnails show the same pixels. - GIF and WebP (D-M3-06-5). A still WebP is stored with its EXIF orientation applied to the
pixels, its ICC profile unchanged (dropped only when it does not fit the output, for example a
CMYK profile) and without any other metadata;
mime_typebecomesimage/pngorimage/jpeg,original_namekeeps its extension (6.1). A GIF or still WebP abovefile.image.max_pixelsor failing to decode, and every animated WebP, stays unchanged as revision 1, becomescompletewithout thumbnails and is shown as a download card. - Revision marker (D-M3-06-12). Every processed original is revision 2 (
orig-2), also when the filter changed no byte; revision 1 is deleted after the switch. The revision inobject_keytells a processed original from an unchanged one.
7.2 Protection against decompression bombs¶
Before decoding, the server reads only the header and dimensions (image.DecodeConfig). If width
times height (for GIF times the frame count) is greater than file.image.max_pixels, it does not
decode: no preview, shown as a download card. Every processing step has the time limit
file.image.processing_timeout; at most file.image.max_concurrent image jobs run per
worker at the same time. JPEG and PNG above the limit are still cleaned, because the filter never
decodes pixels; a GIF or WebP above it stays unchanged (D-M3-06-5).
7.3 Thumbnails¶
| Variant | Longest edge | Purpose |
|---|---|---|
small |
file.thumbnail.small_px |
lists, notifications |
medium |
file.thumbnail.medium_px |
image in the chat |
large |
file.thumbnail.large_px |
full screen, without loading the original |
poster |
like large |
first frame of a video, supplied by the client |
Never larger than the original (no upscaling). Output JPEG with
file.thumbnail.jpeg_quality, PNG with alpha. The server cannot create a video thumbnail in
pure Go; the client sends it with the declaration (poster), and the server treats it like any
image (re-encoding, limits). The server reads width, height and duration of an MP4 or QuickTime
video from the container boxes (mvhd, tkhd) with a small reader of its own.
Rulings of M3-06: the server renders small, medium and large for every decodable image,
also when they end up the same size, from the first frame of an animated GIF or the default
image of an APNG, with the orientation applied and the resampler Catmull-Rom; PNG when the
decoded image is not opaque, JPEG otherwise; the colour profile of the original only when its
colour space fits the thumbnail (no profile for CMYK input); a PNG profile larger than the
APP2 capacity of a JPEG means no thumbnails (D-M3-06-6). width and height of images and
videos are display sizes after orientation or rotation (the tkhd matrix of the first video
track); the duration comes from mvhd, else from mehd; a box error leaves the values absent
and never blocks the file (D-M3-06-9). Every change of the values writes file.updated
(D-M3-06-8). File.thumbnails lists the variants in the order small, medium, large,
poster (D-M3-06-10). The poster of a video is not built: UploadCreate has no field for it.
8. Voice messages¶
8.1 Format and duration¶
- A voice message is a message with
kind = 'voice'and exactly one attachment. - Format of Bothy's own apps: AAC-LC in an MP4 container (
audio/mp4, extension.m4a). This is the only compressed format that the recording package reliably produces on all target platforms [Q19, verified; conclusion derived]. Forkind = 'voice',audio/mpeg(MP3) is also accepted for agents. Other formats:400 validation_failedwitherrors[].codevoice_format_unsupported; they can be sent as a normal file. - Duration: for MP4 the server reads it from
mvhd(time base and duration). For MP3 it takes the client's value for display only. At mostvoice.max_duration; otherwise400 validation_failedwitherrors[].codevoice_too_long.
Check at send (M3-09). createMessage and createThread (first_message) accept
MessageCreate.kind = voice. The message needs exactly one entry in attachment_file_ids: none
gives 400 validation_failed with errors[].code required, more than one limit_reached, both
with the pointer /attachment_file_ids (/first_message/attachment_file_ids in createThread);
content_md may be empty, and a caption with mentions triggers as in a text message
(docs/decisions.d/M3-09.md D-M3-09-8). The attachment rules of section 10 run first, so a file
that is not attachable still answers 409 conflict with reason file_not_attachable. Then the
server checks the locked file row in this order and stops at the first failure
(files/domain.VoiceCheck, D-M3-09-4):
- Upload or scan not ended (
upload_stateorscan_statuspending):409 file_not_ready. - Final but not deliverable (
upload_state = 'failed',scan_statusinfectedorerror):409 file_blocked. - Not a voice format:
400 validation_failedwitherrors[].codevoice_format_unsupported. Voice formats are the detected MP4 audio typesaudio/mp4andaudio/x-m4afor every author, andaudio/mpegin addition for an agent; people and apps send only MP4 audio (D-M3-09-3). - An MP4 with a width or height (it has a video track) or without a duration (the box reader
found no
mvhdduration):400 validation_failedwitherrors[].codevoice_format_unsupported. - A duration above
voice.max_duration:400 validation_failedwitherrors[].codevoice_too_long; a duration equal to it passes.
The 400 answers carry the pointer of the entry (/attachment_file_ids/0, in createThread
/first_message/attachment_file_ids/0). On every failure nothing is stored, and the thread
sequence number is given back. The file must therefore be ready when the message is sent, because
the worker writes the detected type and the duration only after its read pass (3.4, 7.3); a
client keeps a voice message in its outbox until file.updated reports the file ready
(15-client-architecture.md 13). An MP3 voice message has no
duration_ms: no request field carries the client's value, so the duration rule does not apply to
it. The codec inside the MP4 (AAC-LC) is not verified: the API process must not parse audio
(SEC-UP-5), and a wrong codec only fails the playback on a device (D-M3-09-5).
8.2 Transcript¶
voice.transcription_mode:
| Value | Who transcribes | Flow |
|---|---|---|
off |
nobody | audio only |
agent (default) |
the operator agent | The adapter receives the voice message as an attachment with the invocation, transcribes it itself and reports the result with PUT /api/v1/files/{id}/transcript (action file.transcript.submit, 05). transcript_source = 'agent'. |
service |
a transcription service | After clean the worker calls the port Transcriber. Adapter openai_compatible: HTTP endpoint voice.transcription_url (for example a self-hosted Whisper service), key from the secret provider, time limit voice.transcription_timeout, language hint from users.locale. transcript_source = 'service'. |
Default agent, because then no additional processor receives audio. service sends audio
to a service configured by the owner; that service then belongs in the list of processors
(20-privacy.md). The URL is pure instance configuration, never an
input from users (no SSRF path).
file.transcribe (a person taps "Transcribe") starts the job in mode service; in the
other modes the server responds 409 conflict with reason transcription_unavailable.
Result: transcript_status pending -> done or failed, transcript_text,
transcript_language, file.updated. The transcript is searchable
(14-search.md) and available to the agent in its context.
Effective mode. The module files answers the mode through its port Transcripts
(Mode): voice.transcription_mode, except that service counts as off while the switch
voice.enabled is off for the subject. The subject is the author of the voice message (for the
routing of 09-agents.md 13 and the automatic start), the caller (for
file.transcribe) and the uploader (in the job), so audio leaves the server only while the switch
is on for the person whose voice it is (02-architecture.md 2). The modes
agent and off do not depend on the switch (docs/decisions.d/M3-09.md D-M3-09-2).
Automatic start. In the effective mode service the send of a voice message sets
transcript_status = 'pending' (one file.updated in the scope of the message) and inserts the
job files.transcribe in the transaction of the send; in the modes agent and off the file
keeps transcript_status = 'none' (D-M3-09-7).
file.transcribe (requestTranscription, 202 with the Transcript view): an unknown or
invisible file answers 404; the switch voice.enabled off for the caller 403
feature_disabled; a mode other than service 409 conflict with reason
transcription_unavailable; a file that is not ready 409 file_not_ready or 409 file_blocked; a
file that is not a voice format (8.1) 409 conflict with reason transcription_unavailable. A
transcript in state none or failed goes to pending with a new job; in state pending or
done nothing changes (D-M3-09-10, D-M3-09-11). A repeated Idempotency-Key replays the stored
answer.
file.transcript.submit (submitTranscript, mode agent): only the operator agent of the
thread whose message the file is attached to, with read access, stores a transcript; an agent that
cannot read the file, an unattached file of another uploader and an unknown id give the same 404,
and a visible caller who is not that operator gets 403 forbidden (D-M3-09-17). A mode other than
agent and a file that is not a voice format answer 409 conflict with reason
transcription_unavailable; a file that is not ready answers 409 file_not_ready or 409
file_blocked. The request needs If-Match with the transcript ETag: files has no version
column, so the ETag is derived from transcript_status, transcript_source,
transcript_language and transcript_text (03 R-DM-48), the strong value "<first 22
characters of the base64url SHA-256>" over the four fields, each followed by one zero byte;
getFile and getTranscript send the same value in the header ETag (D-M3-09-9). Without
If-Match or with * the answer is 428 precondition_required, with another value 412
version_conflict with the Transcript view in current and the current ETag. text must not
contain U+0000 (errors[].code invalid) and is at most message.max_content_bytes UTF-8 bytes
(too_long); an empty text is a silent recording. language is optional, a BCP 47 tag of a
primary subtag of two or three letters followed by subtags of two to eight letters or digits
(pattern_mismatch). The answer 200 holds status done, source agent, the text, the
language when sent, and the new ETag; a later submit with that ETag replaces the transcript (a
correction). The action writes no audit entry (05-actions.md 4.8).
The job files.transcribe. A run reads the file row outside a transaction and ends without
effect when the row is gone, deleted or no longer pending. When the effective mode of the uploader
is no longer service or the file is not deliverable, it writes failed. Otherwise it sends the
audio to the port Transcriber outside any transaction and stores the result in one transaction
that requires the row still pending, not deleted and with the same object; a changed row is left
as it is. A permanent error of the adapter writes failed at once; any other error is retried by
the job, and the last of voice.transcription_max_attempts attempts writes failed; a database
error never writes failed (D-M3-09-12). failed leaves transcript_text,
transcript_language and transcript_source absent. Both done and failed release a held
invocation in the same transaction (section 10).
Language. The hint is the primary subtag of the uploader's users.locale in lower case (for
example en for en-GB), empty for an invalid locale or a principal without locale. The stored
transcript_language is the language of the service's answer when it is a valid tag, else the
hint, else absent (D-M3-09-13).
Request of the adapter openai_compatible. One POST to voice.transcription_url through
the egress client, which allows exactly the scheme, host and port of that URL, follows no
redirect and ends after voice.transcription_timeout; the answer is limited by
egress.max_response_bytes. A self-hosted service on a private address needs an operator entry
host[:port];allow_private in egress.allowed_hosts. The body is multipart/form-data, streamed
from the object: the part file with the name audio.m4a or audio.mp3 (never original_name)
and the stored mime_type, model only when voice.transcription_model is set, language only
with a hint, and response_format = json. Authorization: Bearer <key> is sent only when
voice.transcription_api_key_ref is set; the key comes from the secret provider per call. The
answer 200 is a JSON object with text (required; valid UTF-8 without U+0000, at most
message.max_content_bytes bytes) and the optional language; other members are ignored.
Timeouts, connection errors, 408, 429 and 5xx are transient; a blocked address, an answer
above the limit, every 3xx, every other 4xx and a malformed or invalid body are permanent
(D-M3-09-15). The adapter logs one line per failed call with the file id, the class and the status
code, never the URL path or query, the key, a response body or text.
8.3 Transcoding¶
Bothy's own apps record AAC-LC and play it on all platforms; no transcoding is needed for
that. A need arises with imports (for example OGG/Opus voice messages,
22-import-migration.md) and with agents that use other formats. For
this there is the port MediaTranscoder. An adapter (for example ffmpeg as a separate
process) is "later"; until then such files are normal audio attachments with download. Storage
of the result: a separate variant playback in file_thumbnails. It comes with that milestone
as an additive migration into the core contract; until then file_thumbnails.variant does not have the value.
9. Download¶
9.1 Pass-through via the server¶
Decision R-FI-03 (ADR 0048):
The server serves all bytes itself (GET .../content). There are no presigned GET URLs to
the storage.
Reasons: every download must go through authorize at download time (B7, 04-sync-protocol.md
section 8: permissions apply at every delivery). A presigned URL stays valid until it expires,
even after revocation. Only the server controls headers such as Content-Disposition and
nosniff. The storage does not need to be reachable for clients. Price: download bandwidth runs
through the server; for a team chat with file.max_upload_bytes this is acceptable.
9.2 File tickets¶
Media players (audio, video) and "Open with" send their own HTTP requests, often with
Range, and cannot produce a DPoP proof per request. For these there are short-lived tickets:
POST /api/v1/files/{id}/tickets {variant}with normal authentication (file.read) returns{url, expires_at}.- The ticket is
base64url(payload || HMAC-SHA-256(key, payload)).payloadcontainsfile_id,variant,principal_id,auth_session_idand expiry (file.ticket_ttl). The key comes from the secret provider (file.ticket_key_ref); two keys are valid at the same time so that a key rotation works without downtime. - On every download with a ticket the server checks: signature, expiry, session not
revoked,
authorize(principal, file.read, file)with current permissions. Every failure yields404. So a ticket does not survive a revocation. - The URL is a bearer secret for a short time. The server removes the parameter
ticketfrom its own logs and telemetry attributes; the collector filters it as well (17-observability.md).
Additions of M3-05 (rulings D-M3-05-1 to D-M3-05-4 and D-M3-05-13 in docs/decisions.d/M3-05.md):
- Session required. A ticket binds to a login session. A principal without one (API token,
agent, app) gets
403 forbiddenwithreasonstep_up_not_possibleoncreateFileTicket(07-permissions.md section 4.1); the rule runs after visibility, so a file the principal cannot see still answers404. - Current and previous key. The server keeps the key it saw before the last version change of
file.ticket_key_refas the previous key and verifies against both; it mints only with the current key. A key change takes effect per process aftersecrets.cache_ttl; tickets minted before keep working in that process until they expire. The previous key lives only in the memory of a process that saw it: a process that starts after the change, and every process after a second change withinfile.ticket_ttl, refuses tickets of the older key, and the player asks for a new ticket. - One answer for every failure. Every failure of the ticket path gives the same
404as an unknown file id: bad encoding or signature, expiry, another file or variant, an unknown or ended session, and every decision ofauthorize(file.read)that is not an allow. - Where a ticket counts. The ticket is accepted only in the query parameter
ticketand only bydownloadFileanddownloadThumbnail(security schemefileTicket); every other operation and every header ignores it. A request with anAuthorizationheader is authenticated by that header, and aticketparameter beside it is ignored (the response then carries theCache-Controlof normal authentication).
9.3 Response¶
| Header | Value |
|---|---|
Content-Type |
detected type; for types outside the inline list application/octet-stream |
Content-Disposition |
inline only for the inline list: stored images per section 7.1 (image/jpeg, image/png losslessly sanitized, image/gif re-encoded), thumbnails, audio/mp4, audio/mpeg, video/mp4. Everything else attachment. Always filename="<ASCII fallback>" first, then filename*=UTF-8''<percent-encoded> [Q15, verified]. |
X-Content-Type-Options |
nosniff |
Content-Security-Policy |
default-src 'none'; sandbox |
Cross-Origin-Resource-Policy |
same-origin |
Cache-Control |
with authentication private, max-age=<client.cache_max_age>, immutable; with a ticket private, no-store |
ETag |
"<sha256 hex>" of the served variant |
Repr-Digest |
sha-256=:<base64>: of the whole variant [Q2, verified]; the client checks it after the download |
Accept-Ranges |
bytes; exactly one range is supported (206), multiple ranges are ignored (200) |
Reason for the strict list: the admin console runs in the browser. HTML or SVG from an upload
must never run there as a page of its own origin. attachment, nosniff and the
sandbox CSP prevent this together.
Files that cannot be served: their metadata (GET /api/v1/files/{id}) is visible to those with
read permission (name, size, state). content, thumbnails and transcript respond with 409
file_not_ready and Retry-After (check running) or 409 file_blocked (blocked,
failed). Without read permission always 404.
Additions of M3-05 (rulings D-M3-05-5, D-M3-05-9 to D-M3-05-12 in docs/decisions.d/M3-05.md):
Retry-After. TheRetry-Afterof409 file_not_readyisfile.not_ready_retry_afterin whole seconds;409 file_blockedcarries none.- Ranges. Only the unit
bytes. Exactly one range in the formfirst-last,first-or-suffixgives206withContent-Range: bytes <first>-<last>/<size>(lastis capped at the end of the variant; a suffix longer than the variant means the whole variant). Several ranges or a syntactically invalid value give200with the whole variant. A range whose first byte is at or after the end of the variant, or a zero suffix, gives416 range_not_satisfiablewithContent-Range: bytes */<size>.If-Rangelets the range apply only when it equals the current strongETag; any other value, an HTTP date included, gives200.If-None-Matchis not evaluated and there is no304, because content never changes once it is deliverable (section 4.4). - Missing variant. A thumbnail variant the file does not have answers
404like an unknown id; clients request only the variants thatFile.thumbnailslists. - Thumbnail digest.
file_thumbnailshas no checksum column, so theETagand theRepr-Digestof a thumbnail are computed from its bytes when it is served. - File names. The name of a thumbnail is the original name without its last extension, then
-<variant>, then.jpgor.pngafter the thumbnail's type. In the ASCII fallback (filename="...") every character outside printable ASCII and every",\,%and;becomes_; an empty name becomesfile.
10. Attaching and agent invocation¶
Rules when a message is sent with attachment_file_ids (createMessage, and createThread in
first_message; message.create with +upload):
- Every file was uploaded by the author (
uploaded_by), haspurpose = 'attachment',scope_channel_id= the channel of the message, an upload session in statecompleted, anupload_stateother thanfailed, nodeleted_atand no attachment yet. A blocked file (infected,error) and a file whose scan has not ended are attachable (3.4 step 3); the invocation rule below protects the agent. - At most
message.max_attachmentsfiles; more give400 validation_failedwitherrors[].codelimit_reachedand the pointer/attachment_file_ids(/first_message/attachment_file_idsincreateThread). - A file is attached to at most one message (03, unique index on
message_attachments(file_id), R-DM-22). Reason: deleting a message deletes its files unambiguously, without reference counting. Forwarding creates a new file. An ID listed twice in one request fails as already attached. - A successful send with
clear_draft: trueremoves the files fromdrafts.file_ids, because the draft is deleted in the same transaction.
Check and answer. The server locks the rows of the listed files (FOR UPDATE, ascending id)
before it checks them, so a concurrent attach of the same file and the outcome write of the scan
wait for the send. It walks the list in request order; the first file that fails rule 1 or 3 ends
the request with 409 conflict with reason file_not_attachable and detail = the JSON
pointer of the entry (zero-based, for example /attachment_file_ids/0 for the first file).
Unknown, foreign, deleted, other-channel, open, aborted, expired, failed and already attached
files give the same status, code, reason and headers, so the answer tells nothing about files of
other people (SEC-UP-9). A unique violation on message_attachments(file_id) from a concurrent
send gives the same answer with the pointer of the list. Nothing of the request is stored, and the
thread sequence number is given back (03 6.2).
Content and rights. With at least one attachment content_md may be empty or white space
only (a picture without caption); without attachments it may not. Sending attachments needs
write and the additional right upload on the channel of the thread; without upload the
answer is 403 forbidden with reason capability_missing. An agent sends files only while the
owner's switch supports_files_out in its operator_config is not false
(09-agents.md 2.2); otherwise the server rejects the message with
403 feature_disabled and stores nothing.
Binding. Each attachment row carries position (request order), the thread_id and the
channel_id of its message (R-DM-43); messages.has_attachments is true. The copies follow the
thread when it moves, in the transaction of the move; files.scope_channel_id counts only before
the file is attached and never changes. An attached file is read through the channel and thread
of its message (05-actions.md 2.2), and its representation (File in
Message.attachments, getFile, file.updated) omits scope_channel_id, because after a move
that channel may be one the reader cannot see. Every representation of a message lists its
attachments in position order; a deleted message lists none.
Deletion with the message. Deleting a message removes its attachment rows, sets
has_attachments to false and sets files.deleted_at of its files in the same transaction
(section 12). It writes no file.updated for these files: none of the fields whose change
section 2 announces with file.updated (upload_state, scan_status, thumbnails, transcript)
changes, and message.deleted already removes the message with its attachments from every
client. The objects stay until files.purge_objects removes them.
Invocation only after the check. If a message with attachments triggers an agent, the
event agent.invocation is written only when all attachments are final (section 2) and,
for kind = 'voice' in mode service, the transcript is also done or failed.
Until then trigger_state = 'received' remains. At the latest after file.invocation_hold_max
(or voice.transcript_hold_max) the invocation is written anyway; the attachments then appear
with their state, not with content. Implementation: the job that makes the last attachment
final publishes the in-tx event files.FileFinalized in its transaction; the module runs
subscribes to it and writes the held-back invocation (02-architecture.md,
section 4.3). The scheduled job runs.invocation_hold_timeout covers the time limit. This is
how 04-sync-protocol.md, 15.3 step 1 states it.
The hold in detail:
- Acceptance holds the run when an attachment of the triggering message is not final: the run is
queued,runs.invocation_held_until(R-DM-44) is the release time, and the jobruns.invocation_hold_timeoutis scheduled for that time. The release time is fixed at acceptance from one clock reading, so a later change offile.invocation_hold_maxdoes not move held runs. ReleaseInvocationwrites the heldagent.invocationexactly once, either in the transaction that makes the last attachment final (subscriber offiles.FileFinalized) or from the job with the attachments in their current state. Both serialize on the run row; the release setsinvocation_held_untiltoNULLand raisesruns.versionby one, withoutrun.state_changed. A run that ended while held (for example throughruns.queued_expiryor a cancel) keeps the column and is never released.- The released invocation has no
actorand nocorrelation_id, because its writer is a job without principal; the trigger stays intriggered_by. Scope, billing mode and settings version are those of the moment of the release; the rights are checked by the delivery as for every invocation (04 15.3 point 2). - The follow-on run of deferred follow-ups is held like an acceptance when its triggering message
has an attachment that is not final. A follow-up (
agent.followup) to an existing run is not held: the core contract holds onlyagent.invocation(04 15.3 point 1), and the agent reads attachments only throughfile.read, which serves no bytes beforescan_status IN ('clean','skipped')(9.3).
The window depends on why the attachments are not final (files.Attachments.Pending): when an
attachment is not final in the sense of section 2 the run waits at most
file.invocation_hold_max; when every attachment is final but a transcript is still pending
(only mode service sets it, 8.2) it waits at most voice.transcript_hold_max
(runs/domain.HoldWindow). The release on files.FileFinalized happens only when no attachment is
pending for either reason; the release by the job writes the invocation with the transcript still
pending. The follow-on run of deferred follow-ups uses the same window (docs/decisions.d/M3-09.md
D-M3-09-16).
Reasons: an agent never receives unchecked bytes. Holding back in the gateway delivery would
block the events of a consumer behind the waiting invocation, because they run in
seq order (04-sync-protocol.md 15.1); writing late
avoids that.
Notifications do not wait for attachments (13-push-notifications.md).
11. Files from agents and from imports¶
Reply attachments from agents. Agents upload through the same flow (session, parts,
completion) and attach to their own messages in the thread of the run. Like people they need
write and the capability upload on the channel; the limit is their grant
(05-actions.md 2.6). uploaded_by is the agent. Same scan, same
limits, same quota. Which local files an adapter may upload at all (allow list of paths, deny
list for secret files) is governed by the adapter
(09-agents.md); the server checks only permissions, limits and content. Inside a
run the upload is bound to the run (07 SR9): an agent uploads only where the triggering person may
upload too; the answer in the run's thread is an exception of SR9.
Import attachments. The import tool (22-import-migration.md)
fetches the files and uploads them through the import interface: createImportFile writes the
pending file item and reserves the file ID with an upload session of mode direct
(declared_sha256 from the request), uploadImportFilePart and completeImportFile carry the
parts and the completion with the rules of 3.2 and 3.4 under import.apply instead of
file.upload, so a read-only mirror channel accepts them (05 rule 1.8 and 4.12). The item becomes
posted in the unit of work of the completion; a repeated createImportFile returns the same
file ID, with a new session after an aborted or expired one. After the completion the same path
from 3.4 applies: checksum, type, scan, metadata, thumbnails. Differences: no quota (the open
sessions and the free space still hold); the jobs files.scan and files.thumbnail of an import
file stand on the queue files with the lowest River job priority (their arguments carry the
mark bulk = 1, which their retries and thumbnails inherit), so a worker always fetches the jobs of
live uploads first and a large import does not delay them, without a queue or a configuration key
of its own; the messages are silent. uploaded_by is the principal to whom the import assigns
the message, and the file attaches only to a message of that author in the channel it was
uploaded for (10).
12. Retention, deletion, deduplication¶
| Case | Effect |
|---|---|
| Message deleted | In the same transaction: message_attachments removed (03-data-model.md), files.deleted_at set, an open upload session aborted, the payloads of the stored file.updated events of the file redacted (R-DM-45: original_name empty, size_bytes 0, mime_type application/octet-stream, thumbnails empty, every optional member dropped), job files.purge_objects queued (02 12.3). The job deletes, outside every transaction, the originals orig-1 to orig-(r+1) (r the revision of the stored key), the four variant keys and the parts or the multipart upload, and then the row (R-DM-46). No event announces the deletion. |
file.delete (deleteFile) |
only own files that are not attached (or with manage, as authorize decides); same path. An attached file answers 409 conflict with reason file_attached (delete the message), an avatar, logo or export file 409 conflict with reason purpose_not_deletable. A file the caller already deleted answers 204; an unknown, foreign or purged file the 404 of an unknown id |
| Avatar replaced or removed, logo replaced or removed | In the same transaction as the change of principals.avatar_file_id or branding.logo_file_id: files.deleted_at of the previous file is set; its objects go through files.purge_objects (M3-08). A staged file whose session never completes expires like every open session (row below). |
| Never attached | Files with purpose = 'attachment', upload_state = 'complete', without an attachment and without a reference from a draft of the uploader (drafts.file_ids with drafts.user_id = files.uploaded_by), older than file.unattached_ttl: delete. Reason for "of the uploader": a draft checks file_ids on saving only for their form (04 section 14); a foreign ID in it must not keep a file alive. Drafts protect only this row |
| Session open and expired | after file.upload_session_ttl: delete the parts, abort the multipart upload, upload_state = 'failed' with file.updated, session expired |
| Failed or blocked | delete the objects immediately: the transaction of the transition to failed, infected or error queues files.purge_objects beside the deletes after the commit; the row of an attachment file that hangs on no message stays until file.failed_row_ttl for display to the uploader and is then deleted. Blocked files (complete with infected or error) follow this row. An attached blocked file keeps its row |
| Avatar and branding rows | not touched by the cleanup: they go when their release marks them deleted (row above) |
| Retention periods | class content; periods and GDPR deletion in 20-privacy.md |
| Backups | Deleted objects remain in backups until the backup period expires (16-operations.md) |
A cleanup job runs every file.gc_interval and catches up on missed deletions. A
failed Delete is retried; a missing object counts as deleted.
The cleanup job files.cleanup_pending (M3-08, docs/decisions.d/M3-08.md) reads the clock once and
runs its steps in this order, each in batches of file.gc_batch_size with one short transaction
per file: expired open sessions; never attached files; failed and blocked rows; the catch-up, which
purges every row marked deleted inline and stops at the first storage error until the next run;
the temporary files of the file system driver (4.2); the deletion backlog (section 14). The age of
a file for file.unattached_ttl and file.failed_row_ttl counts from its latest stored time
(created_at, the completion of its session, scanned_at, and expires_at of an expired
session), so a file whose upload or scan took long keeps its full window. No object store call
runs inside a database transaction (02 8.5), and the server never lists the store (4.1).
No deduplication (R-DM-13, confirmed). Privacy reasons: (1) Anyone who uploads a known file and notices from the timing or the response that it is already there learns that another person has it; without deduplication this side channel does not exist. (2) Deletion (also under the GDPR) removes the bytes reliably, because no second reference holds on to them. (3) Every file has exactly one channel as its permission anchor. Price: double storage for files shared several times.
13. Endpoints and actions¶
docs/api/openapi.yaml and 06-api.md are binding; this table reflects the state
of the spec.
| Method and path | Action | RL class |
|---|---|---|
POST /api/v1/channels/{channel_id}/uploads |
file.upload |
rl.upload |
GET /api/v1/uploads/{file_id} |
file.upload (own session, confirmed parts) |
rl.upload |
PUT /api/v1/uploads/{file_id}/parts/{n} |
file.upload (own session) |
rl.upload |
POST /api/v1/uploads/{file_id}/complete |
file.upload (own session) |
rl.upload |
DELETE /api/v1/uploads/{file_id} |
file.delete (own file, not attached) |
rl.write |
GET /api/v1/files/{id} |
file.read (metadata) |
rl.read |
GET /api/v1/files/{id}/content |
file.read |
rl.read |
GET /api/v1/files/{id}/thumbnails/{variant} |
file.read |
rl.read |
POST /api/v1/files/{id}/tickets |
file.read |
rl.read |
POST /api/v1/files/{id}/transcribe |
file.transcribe |
rl.upload |
PUT /api/v1/files/{id}/transcript |
file.transcript.submit |
rl.agent_runtime |
DELETE /api/v1/files/{id} |
file.delete |
rl.write |
Avatar and logo have no upload sessions of their own: they arrive as one multipart/form-data
request with the part file (ImageUpload) and use the action of 3.7, and the server opens and
completes the session itself (3.7).
| Method and path | Action | RL class |
|---|---|---|
PUT /api/v1/me/avatar (setMyAvatar) |
self.profile.update |
rl.write |
DELETE /api/v1/me/avatar (deleteMyAvatar) |
self.profile.update |
rl.write |
PUT /api/v1/instance/branding/logo (uploadBrandingLogo) |
instance.branding.update |
rl.admin |
DELETE /api/v1/instance/branding/logo (deleteBrandingLogo) |
instance.branding.update |
rl.admin |
GET /api/v1/instance/branding/logo (getBrandingLogo) |
instance.discovery.read (public, variant medium) |
rl.public_auth |
14. Observability¶
Signals (names binding in docs/observability/signals.md, Team F): uploads by result,
bytes, duration of the verification pass and the scan, scan results by scan_status, age of the
oldest pending file, clamd reachability, image jobs by result, deletion backlog (the gauge
bothy.file.purge.backlog: files marked deleted whose purge is still due, M3-08). No file names,
no content, no signature names in metric attributes (cardinality and privacy);
signature names only in the audit log.
15. Tests¶
| Test | Holds |
|---|---|
| Driver conformance: the same test suite against the file system and against SeaweedFS (testcontainers) | port contract, ranges, multipart, deletion of missing objects |
The S3 driver depends only on a Go interface with the port operations; architecture rule: only internal/files/adapters/s3 imports the S3 SDK (and, since M7-22, the audit checkpoint sink internal/audit/adapters/s3sink; since M7-02, the backup mirror internal/status/adapters/s3mirror) |
"core S3 only", no DeleteObjects |
| EICAR test file against a real clamd container | infected, never servable, objects deleted, audit entry |
| clamd stopped | file stays pending, 409 file_not_ready; after the start clean |
Archive above MaxRecursion |
Heuristics.Limits.Exceeded becomes error, not clean |
| Startup check | the server does not start with file.max_upload_bytes > file.scan.clamd_stream_max_bytes and required |
| Total checksum wrong (manipulated part in presigned mode, fake S3) | upload_state = 'failed' |
| Part sent twice, identical or different | 200 or 409 conflict with reason part_conflict respectively |
JPEG with GPS EXIF, XMP, APP2 with Multi-Picture Format, an appended image and orientation 6 |
stored original with exactly one APP1 (only "Orientation" = 6), APP2 only ICC_PROFILE, scan data identical byte for byte, nothing after the end of the image; thumbnails with the orientation applied and the same color profile |
| truncated JPEG | upload_state = 'failed', no object in storage |
| Image with huge dimensions and a small file size | no decoding, no preview, no rise in peak memory above a test limit |
| HTML and SVG upload | Content-Disposition: attachment, nosniff, sandbox CSP |
File name with U+202E, ../, control characters |
sanitized name, both filename parameters correct |
| Someone else's file ID and a non-existent ID | identical 404 response (B7) |
| Ticket: range download; after revocation of permissions, after session revocation, after expiry | 206; three times 404 |
| Attaching the same file a second time | 409 conflict with reason file_not_attachable |
| Agent invocation with an attachment while clamd is down | agent.invocation only after clean; after file.invocation_hold_max with state pending |
| Cleanup | a never attached file is gone after the period; a file in a draft stays |
| Key schema | property test: no key contains a channel ID, space ID or names |
16. Decisions of this chapter¶
| No. | Decision | Reason |
|---|---|---|
| R-FI-01 | Upload always through a session with an upfront declaration of size, type and SHA-256, in parts of fixed size. | Early rejection; mobile uploads can be resumed; one part plan for both drivers. |
| R-FI-02 | Mode direct is the default; presigned only with S3 and only after proof of the length binding. |
Private networks often do not reach the storage; without length binding a client can store more than declared. |
| R-FI-03 | Download only through the server, no presigned GET; tickets for players. | authorize at download time; revocation takes effect immediately; headers under control. (ADR 0048) |
| R-FI-04 | One read pass for checksum, type and scan. | Large files are read only once; the same path for all modes. |
| R-FI-05 | Scan fail closed (required) as the default; off only explicitly, visibly marked. |
No unchecked byte to recipients or agents. |
| R-FI-06 | AlertExceedsMax yes is mandatory; limit hits become error. |
Otherwise unchecked content silently counts as clean. |
| R-FI-07 | For JPEG and PNG, metadata is removed losslessly from the original (allow list of segments and chunks), thumbnails are re-encoded; orientation and ICC profile stay; unsupported formats only as a download. | Ruling L16, ADR 0047: location data never leaves the sender, the original keeps its quality, colors and orientation stay correct. |
| R-FI-08 | A file is attached to at most one message. | Unambiguous deletion, no reference counting. |
| R-FI-09 | Agent invocation with attachments only after a final state, with a time limit. | Agents never see unchecked bytes; no blocking of the event stream. |
| R-FI-10 | Transcript by the agent by default; a service only by owner configuration. | No additional processor without a decision by the owner. |
| R-FI-11 | No deduplication (R-DM-13 confirmed). | No existence side channel, reliable deletion. |
17. Alignment with the core contract¶
The additions requested during drafting are decided (integration of 2026-10-10):
| No. | Addition | Decision, location |
|---|---|---|
| 1 | Table file_upload_sessions |
adopted, 03 section 4.7 |
| 2 | Table file_upload_parts |
adopted, 03 section 4.7 |
| 3 | files.purpose and read rules per purpose |
adopted, 03 files, 05 section 2.2 |
| 4 | unique index message_attachments(file_id) |
adopted, 03, R-DM-22 |
| 5 | Action file.transcript.submit |
adopted, 05 section 4.8 |
| 6 | Audit on infected |
adopted, 05 file.upload (audit on malware detection), reason malware_detected in 07-permissions.md |
| 7 | later writing of agent.invocation |
adopted, 04 section 15.3 step 1 |
| 8 | Variant playback in file_thumbnails |
not now: comes with the milestone that builds transcoding (8.3), as an additive migration |
18. Configuration values of this chapter¶
Values from other chapters (reference only): file.max_upload_bytes, message.max_attachments
(03-data-model.md), client.cache_max_age
(04-sync-protocol.md).
| Name | Default | Meaning |
|---|---|---|
file.storage.driver |
fs |
fs or s3 |
file.fs.root |
/var/lib/bothy/files |
root of the file system driver (path in the container) |
file.fs.min_free_bytes |
2147483648 |
free space below which uploads are rejected with 503 unavailable (reason storage_full) |
file.s3.endpoint |
empty | S3 endpoint |
file.s3.region |
us-east-1 |
region for the signature |
file.s3.bucket |
empty | bucket |
file.s3.prefix |
empty | prefix before all keys |
file.s3.use_path_style |
true |
path style instead of virtual hosts |
file.s3.checksum_mode |
when_required |
SDK checksums for request and response: when_required or when_supported (only with an https endpoint) |
file.s3.access_key_ref |
file:<secrets.dir>/s3_access_key |
reference to the secret provider; read only with file.storage.driver = s3 |
file.s3.secret_key_ref |
file:<secrets.dir>/s3_secret_key |
reference to the secret provider; read only with file.storage.driver = s3 |
file.s3.presigned_upload |
false |
allow mode presigned |
file.s3.presign_ttl |
15m |
validity of a presigned URL |
file.upload_part_bytes |
8388608 |
part size |
file.upload_session_ttl |
24h |
lifetime of an open session |
file.upload_max_open_sessions |
20 |
open sessions per principal |
file.quota_bytes_per_principal |
0 |
quota per principal, 0 = off |
file.poster_max_bytes |
524288 |
size of the video thumbnail from the client |
file.sniff_bytes |
1048576 |
bytes for type detection |
file.denied_mime_types |
empty | blocked types |
file.name_max_bytes |
255 |
length of a file name in UTF-8 bytes |
file.scan.mode |
required |
required or off |
file.scan.clamd_address |
unix:///run/clamav/clamd.sock |
Unix socket of clamd, unix:// plus an absolute path; no other transport (19 SEC-TLS-5) |
file.scan.chunk_bytes |
65536 |
chunk size for INSTREAM |
file.scan.timeout |
120s |
time limit per scan |
file.scan.max_concurrent |
4 |
scans per worker process |
file.scan.retry_backoff_cap |
5m |
largest interval between scan attempts |
file.scan.health_interval |
30s |
PING interval |
file.scan.pending_alert_after |
10m |
age of the oldest pending file before the alert |
file.scan.clamd_stream_max_bytes |
104857600 |
mirror of clamd StreamMaxLength |
file.invocation_hold_max |
120s |
longest wait of the agent invocation for attachments |
file.image.libvips_enabled |
false |
use the libvips build |
file.image.max_pixels |
50000000 |
largest decoded pixel count |
file.image.jpeg_quality |
90 |
quality when converting still WebP images without alpha to JPEG |
file.image.processing_timeout |
60s |
time limit of an image job |
file.image.max_concurrent |
2 |
image jobs per worker process |
file.thumbnail.small_px |
320 |
longest edge small |
file.thumbnail.medium_px |
1024 |
longest edge medium |
file.thumbnail.large_px |
2048 |
longest edge large |
file.thumbnail.jpeg_quality |
82 |
quality of the thumbnails |
file.ticket_ttl |
5m |
validity of a file ticket |
file.ticket_key_ref |
file:<secrets.dir>/file_ticket_key |
reference to the ticket key in the secret provider |
file.not_ready_retry_after |
5s |
Retry-After of the 409 file_not_ready responses (in whole seconds) |
file.unattached_ttl |
24h |
period for never attached files |
file.failed_row_ttl |
7d |
period for rows of failed or blocked files |
file.gc_interval |
10m |
interval of the cleanup job |
file.gc_batch_size |
500 |
candidates per batch of a cleanup step |
voice.max_duration |
15m |
longest voice message |
voice.transcription_mode |
agent |
off, agent or service |
voice.transcription_url |
empty | endpoint of the transcription service |
voice.transcription_api_key_ref |
empty | reference to the key |
voice.transcription_timeout |
120s |
time limit of a transcription |
voice.transcript_hold_max |
60s |
longest wait of the agent invocation for the transcript |
voice.transcription_model |
empty | model field of the transcription request; empty sends no model field |
voice.transcription_max_attempts |
5 |
attempts of the job files.transcribe before transcript_status = failed |
19. Sources¶
All retrieved on 2026-10-10.
- Q1 Amazon S3, Multipart upload limits: https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html [verified]
- Q2 RFC 9530 Digest Fields: https://www.rfc-editor.org/rfc/rfc9530.html [verified]
- Q3 AWS SDKs and Tools, Data integrity protections: https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html [verified]
- Q4 Consequences of the SDK checksums for S3-compatible services: https://gitlab.com/gitlab-org/gitlab-runner/-/merge_requests/6691 and https://gitlab.com/gitlab-org/container-registry/-/issues/2309 [verified, read only via a search summary]
- Q5 SeaweedFS README: https://github.com/seaweedfs/seaweedfs [verified]
- Q6 MinIO repository (archived): https://github.com/minio/minio [verified]
- Q7 ClamAV, ClamD protocol: https://docs.clamav.net/manual/Usage/ClamdProtocol.html and https://docs.clamav.net/manual/Usage/Scanning.html [verified]
- Q8 go-clamd in the Go module proxy: https://proxy.golang.org/github.com/dutchcoders/go-clamd/@latest [verified]
- Q9 ClamAV in Docker: https://docs.clamav.net/manual/Installing/Docker.html [verified]
- Q10 clamd.conf.sample: https://raw.githubusercontent.com/Cisco-Talos/clamav/main/etc/clamd.conf.sample [verified]
- Q11 Docker Hub tags
clamav/clamavandclamav/clamav-debian: https://hub.docker.com/v2/repositories/clamav/clamav-debian/tags/stable [verified] - Q12 ClamAV COPYING: https://raw.githubusercontent.com/Cisco-Talos/clamav/main/COPYING.txt [verified]
- Q13 GNU GPL FAQ (MereAggregation): https://www.gnu.org/licenses/gpl-faq.html [verified]
- Q14 mimetype: https://pkg.go.dev/github.com/gabriel-vasile/mimetype [verified]
- Q15 RFC 6266 Content-Disposition: https://www.rfc-editor.org/rfc/rfc6266.html [verified]
- Q16 Go
image/jpeg: https://pkg.go.dev/image/jpeg [verified] - Q17 govips README: https://github.com/davidbyttow/govips [verified]
- Q18 Go
x/image/webp: https://pkg.go.dev/golang.org/x/image/webp [verified] - Q19 Flutter package
record: https://pub.dev/packages/record [verified]