| D-M3-03-1 |
2026-10-19 |
UploadCreate.sha256 is required and lowercase hex (^[0-9a-f]{64}$); the verify pass checks it. |
The base64 form of 12 3.1 and the optional hex of openapi disagreed; a required hex value is what the client already computes and what File.sha256 shows (design note 19). |
| D-M3-03-2 |
2026-10-19 |
Parts are stored per driver through one PartStore (internal/files/app/partstore.go), chosen in internal/wire from file.storage.driver: fs writes part n as the object uploads/<file_id>/<n>, discards by deleting every key from 1 to part_count; s3 creates the multipart upload lazily at the first part outside any transaction, stores its id with a conditional update (the loser aborts its own upload), uploads part n and keeps the ETag, discards with AbortMultipart. CompleteMultipart and the joining of file system parts run in the verify pass of M3-04, not in completeUpload. |
createUpload and completeUpload accept Idempotency-Key, the idempotency stage runs their handlers inside the unit of work (02 8.4), and 02 8.5 forbids object store calls inside a transaction; 12 3.2 and 3.4 are updated. |
| D-M3-03-3 |
2026-10-19 |
The ETag of a file system part is the quoted lowercase hex SHA-256 of the part, derived from file_upload_parts.sha256; file_upload_parts.etag stays NULL for it. |
03 names etag the ETag of an S3 part; the derived value equals the ETag M3-01's driver computes (M3-01/6). |
| D-M3-03-4 |
2026-10-19 |
Check order of createUpload: authorize, size, name cleaning with the early type check, then in one unit of work quota, open sessions, free space, insert. |
12 3.1 numbers authorize, size, quota, sessions and space; the type check needs no database, so it comes right after the size. |
| D-M3-03-5 |
2026-10-19 |
Early type check: with an empty file.denied_mime_types nothing is checked (an unparsable declared type is then stored as NULL); otherwise the declared type without parameters in lower case (mime.ParseMediaType; unparsable: 400 validation_failed, pointer /declared_mime_type, code invalid) or the extension of the cleaned name, ignoring case, against the extension of each denied type gives 415 unsupported_media_type with reason file_type_denied. Extensions come from github.com/gabriel-vasile/mimetype (mimetype.Lookup, one extension per type) through the port TypeExtensions. |
The standard library's mime.ExtensionsByType reads tables of the operating system and differs between hosts; 12 6.1 names the library; the second check against the detected type is M3-04. |
| D-M3-03-6 |
2026-10-19 |
The quota sums size_bytes of the principal's files with deleted_at IS NULL AND upload_state <> 'failed'. |
Failed files count as deleted because their objects go at once (12 12); an aborted upload must not block the quota until file.failed_row_ttl. |
| D-M3-03-7 |
2026-10-19 |
Before the quota and session counts createUpload takes pg_advisory_xact_lock(hashtextextended('bothy.files.uploads:' \|\| principal, 0)), a domain lock in step 2 of 03 6.3. |
Without it two parallel creates of one principal both pass a limit (TestCreateUpload_ParallelAtLimit). |
| D-M3-03-8 |
2026-10-19 |
At file.upload_max_open_sessions open sessions (state open, expires_at in the future) createUpload answers 429 rate_limited with Retry-After until the earliest expiry, rounded up to whole seconds and at least one; detail names the key. |
12 3.6 names no code, and rate_limited is the only code with status 429. |
| D-M3-03-9 |
2026-10-19 |
Name cleaning: NFC (port NameNormalizer, adapter with golang.org/x/text/unicode/norm), the last segment after a slash or backslash, invalid UTF-8, Cc controls, the bidi controls of kernel/textcheck (now exported as IsBidiControl) and the zero-width characters U+200B to U+200D, U+2060, U+FEFF removed, trimmed; empty, . and .. become file; above file.name_max_bytes the extension stays when it alone is shorter than the limit and the stem is cut at a rune boundary. Names are never rejected, and the schema minimum of file.name_max_bytes is 4 so file fits. |
12 6.2; a 400 for an already cleaned name gives the client nothing to correct; one source for the bidi set. |
| D-M3-03-10 |
2026-10-19 |
Content-Digest is parsed as an RFC 8941 dictionary; the member sha-256 must be a byte sequence of 32 bytes, other members are ignored. Malformed: 400 validation_failed, pointer #header/Content-Digest, code invalid; missing: the request validator's code required at the same pointer (the header is required in openapi). A wrong digest answers part_digest_mismatch at #header/Content-Digest, a wrong length (a shorter or longer body, an early end) part_digest_mismatch with an empty pointer. |
RFC 9530; the validator of M1-18 answers a missing required header before the handler, with its own code. |
| D-M3-03-11 |
2026-10-19 |
Answers per session state: uploadPart and getUpload give 410 gone for aborted, expired or an open session past expires_at; a completed session answers getUpload with complete and a repeated part with the confirmed one; completeUpload answers a completed session 200 with the file and an ended one 410; abortUpload answers aborted or expired 204 without change and completed 409 invalid_state_transition. |
06 7 (a DELETE of an ended resource succeeds); 12 3.6; a completed session has no expiry of its own (12 2). |
| D-M3-03-12 |
2026-10-19 |
UploadComplete.parts is cross-checked: a duplicate number 400 (pointer /parts, code duplicate), a number outside 1 to part_count 400 (code out_of_range), a listed etag that differs from the confirmed one 409 conflict part_conflict; the list may name fewer parts than exist; missing confirmed parts give 409 conflict upload_incomplete with the missing numbers as ranges in detail. |
In mode direct the confirmed rows are the truth (12 3.4 point 1). |
| D-M3-03-13 |
2026-10-19 |
UploadState.upload_state maps the session: open without a confirmed part pending, open with at least one uploading, completed complete. |
The schema has its own enum, not the one of File.upload_state. |
| D-M3-03-14 |
2026-10-19 |
The same new part number sent twice in parallel: both stream, the first committed row wins, the other answers 200 with the same digest or 409 conflict with reason part_conflict; the later write may replace the bytes of the winner, which the verify pass of M3-04 catches with checksum_mismatch (and on S3 the stored ETag no longer matches at CompleteMultipart). A confirmed part is never written again. |
12 4.4 fixes the part key; no wrong byte is ever delivered. |
| D-M3-03-15 |
2026-10-19 |
A lost read right (authorize answers not_found for an existing own session) aborts the open session in its own unit of work in uploadPart, getUpload and abortUpload, then answers 404; completeUpload only answers 404; a 403 aborts nothing. |
12 3.6; the idempotency stage rolls back every final error of a keyed request, so an abort there cannot persist; the session ends at the next request without key or by expiry (M3-08). |
| D-M3-03-16 |
2026-10-19 |
file.updated carries audience_principal_id = uploader before attachment; createUpload and completeUpload write no event. The builder app.FileUpdated is kept for M3-04 to M3-10. |
ResourceFromEvent carries neither uploader nor attachment, so without an audience the hub and catch-up would deliver the file name to every reader of the channel (05 2.2); 03 event_log allows an audience with a receive action other than self; neither state of the file changes on create or complete. |
| D-M3-03-17 |
2026-10-19 |
Visibility of kind file: purpose attachment and not attached only for the uploader with read on the path of the scope channel (dm: participant); attached for readers of the message's path; avatar, branding, gdpr_export not visible until M3-10 and M7-06. authorize.Resource.File (FileFacts{Purpose, Attached}) is required in request mode, a missing value is an internal error; only filter and delivery mode, which decide stored events by their scope, may leave it out. file.read requires read; file.delete keeps self or manage with the same visibility. |
05 2.2 and 07 4.3; fail closed, so no caller widens access by omitting the facts. |
| D-M3-03-18 |
2026-10-19 |
File.sha256 is shown only when upload_state = 'complete'. |
The schema says "of the stored bytes"; before the verify pass only the declared value exists. |
| D-M3-03-19 |
2026-10-19 |
Start checks: upload_part_plan (a file of file.max_upload_bytes in parts of file.upload_part_bytes has at most the S3 part-count limit of parts), upload_part_size_s3 (with the driver s3 the part size is not below the S3 minimum part size), presigned_mode_unavailable (file.s3.presigned_upload = true fails). The S3 limits are protocol constants (12 3.1, Q1). |
Otherwise every upload of more parts would fail at CompleteMultipart, and the presigned mode stays off until the proofs of 12 3.3 exist (R-FI-02, RK-42). |
| D-M3-03-20 |
2026-10-19 |
abortUpload commits the abort and file.updated first and calls PartStore.Discard after the commit; a storage error there is logged with bothy.file.id only and the answer stays 204. |
02 8.5; leftovers belong to the cleanup of failed files (12 12, M3-08). |
| D-M3-03-21 |
2026-10-19 |
On the original base only (superseded on main by D-M3-03-28, which keeps the examples): docs/api/openapi.yaml drops the generic summary: Request and summary: Response of its request and response examples (the example key default stays); no schema, parameter, response or operation changes. This gives about 14 KB of room below repo.max_file_kb. |
The base had 18 bytes left below the limit (D-M6-01-9 named the next contract change as the point where openapi.yaml must be split, an owner decision). The two summaries carry no information beyond the key, no gate or generator reads them, and the gate must not be loosened; the split stays the owner's decision (report). |
| D-M3-03-22 |
2026-10-19 |
uploadPart passes the length to the use case as unknown: the strict handler of the generated layer does not see Content-Length. The verifying reader stops after one byte more than the part length, so a wrong Content-Length (the HTTP server bounds the body by it) still answers 400 validation_failed with the field code part_digest_mismatch and stores nothing, after reading at most the part length plus one byte. |
The brief's early refusal before any byte would need the request in the handler; changing the generated layer or the chain is outside this ticket. |
| D-M3-03-23 |
2026-10-19 |
files.PutPart carries the raw ContentDigest header instead of a parsed digest, so the use case checks it after the session, the authorization, the state and the part number (design note 9 order); files.PutPart and the session DTOs otherwise follow the brief. |
A malformed digest on a foreign or unknown file would otherwise answer 400 before the 404. |
| D-M3-03-24 |
2026-10-19 |
Every role opens the object store in a new start step files after chat (FilesObjectStore; the driver fs checks file.fs.root), and the stop step of the pool closes it. The server image gains the empty directory /var/lib/bothy/files (owner 65532, mode 0700) next to the key store, and the test servers (harness, serveEnv, cmd/bothy) get a temporary root. |
M3-01 left opening the store and the image directory to this ticket (README of files); without the directory a named volume would be root-owned and the start check of the driver would refuse it. |
| D-M3-03-25 |
2026-10-19 |
bothy.file.upload.size is added to the metric registry platform/otel/metrics (with the size bucket row of signals.md 4.7) and recorded after the commit of completeUpload. |
The registry is the only way to create an instrument (M1-68); the after-commit hook keeps a rolled-back completion uncounted. |
| D-M3-03-26 |
2026-10-19 |
Base and texts: branch feat/M3-03-upload-sessions from int/m1-wave46 (9c05469), not merged locally and not pushed (the task of this session). The chapters of this base are German files (12-dateien.md, 06-api.md, 19-sicherheit.md, 03-datenmodell.md); the brief's English names map to them, and added text is English. The start check runs as the full make check at the end instead of before the work. |
The launcher's task takes precedence over the brief's landing step; M3-01/14 did the same; several suites already ran on the machine at the start. |
| D-M3-03-27 |
2026-10-19 |
The integration tests of the brief are subtests of four server-scoped tests in test/uploads (one harness server per configuration), and the expiry is tested with file.upload_session_ttl of two seconds and a real wait instead of a fake clock. |
A harness server stops with the test that started it; the DPoP proofs of the harness client use the real time, so a fake server clock far ahead would refuse them. |
| D-M3-03-28 |
2026-10-21 |
Port onto main from the ticket branch (88c2418..0b60fd7, integrated as one squash on int/m1-wave47) after M1-69, without the commit that brought the planning ruling D-W-20 into the base: main has its delta for the upload operations already. The German chapter edits go into the English chapters 12, 06 and 19; code comments name the English chapter files; the migrations become 00046 and 00047 after 00045 of M2-07 (comments changed, checksums locked again); file.read and the visibility of kind file land without the HostAuth field and the host rows of M2-43, which is not on main, so host.connect and host.control.session.open stay deferred in the tests; the strict objects of api/rest are cut in two by the generator that M2-07 brought to main (splitAll, halveObjects); the REST layer, the route table, the clients, config-schema.json, the configuration reference and .go-arch-lint.yml are generated again; the umlaut sample of the normalizer test is an accented Latin letter; docs/api/openapi.yaml of main keeps the generic summary: Request and summary: Response examples, so the part of D-M3-03-21 about them applies only to the base. |
The PR lane ports only the ticket; English documents on main win; the split is the one M6-01 brings, so the tickets carry the same code. |
| D-M3-03-29 |
2026-10-21 |
Every lookup of an upload session by its uploader first checks for an acting principal (user, agent or app); without one, getUpload, uploadPart, completeUpload and abortUpload answer 401 unauthenticated before the session is read, not the 404 of a session the caller does not own. |
The contract check ignored_auth of G7 on main expects 401 or 403 for a request without credentials; authorize gives 401 before any resource is read (SR7). |
| D-M3-03-30 |
2026-10-21 |
After M2-43 landed on main, the port is rebased onto it: authorize.Resource carries HostAuth and File side by side, host.connect is no longer deferred, and the authz tests take the ticket's original form (the registry of file.updated verifies; the deferred list of the cache test is command.invoke, file.transcript.submit and import.apply). This replaces the part of D-M3-03-28 about M2-43. |
main now has M2-43, which the original ticket base also had, so the original resolution applies. |
| D-M3-03-31 |
2026-10-21 |
In the canary test Alex may read the scope file before attachment: Readable["Alex"] holds the Support scopes of M2-02 plus the file scope; Sam, Robin and Lotse do not. This is the fix INT-W48/1 of the integration branch, which lands with the second of M2-02 and M3-03 to reach main. |
M2-02 made Alex an observer of the foreign sink, and M3-03 lets Alex upload the canary file name, whose file.updated reaches the uploader alone (D-M3-03-16); together the run reported Alex's own frames and event pages as findings. Test code only. |