M3-03: Upload sessions, parts and completion¶
Status: ready | Depends on: M1-69, M1-70, M3-01 | Milestone: M3
Goal¶
A person, agent or app with write and the capability upload on a channel announces a file with name, size and SHA-256, sends it in parts of fixed size that the server checks by length and Content-Digest while reading, resumes after a network loss, and completes or aborts the upload, all in mode direct. The files module owns the tables files, file_upload_sessions and file_upload_parts exactly as 03 defines them, adds the deferred foreign key principals.avatar_file_id, enforces file.max_upload_bytes, the quota per principal, the number of open sessions and the free-space reserve of the file system driver, and enables createUpload, uploadPart, getUpload, completeUpload, abortUpload and getFile. Until a file is attached only its uploader can read it; every change of upload_state writes file.updated for that uploader only; the object key never carries a name; the file name is a canary path. A completed upload stays pending until the verify pass of M3-04 runs, so nothing here ever delivers bytes (12 "Goals and limits" point 1). This is the upload half of 12 sections 3 and 13 and the base for scan, download, images, attachments and cleanup.
Read first¶
docs/spec/12-files.md: 1 "Goals and limits", 2 "States of a file", 3.1 "Creating an upload session", 3.2 "Transferring parts (modedirect, default)", 3.3 "Presigned PUT (modepresigned, optional)" (why the mode stays off), 3.4 "Completion and verification pass", 3.5 "Sequence (direct upload with scan)", 3.6 "Error cases", 3.7 "Purpose of a file", 4.1 "Port", 4.2 "File system driver (default)", 4.3 "S3 driver (core S3 only)", 4.4 "Key schema", 6.1 "Type", 6.2 "File names", 12 "Retention, deletion, deduplication", 13 "Endpoints and actions", 14 "Observability", 15 "Tests", 16 (R-FI-01, R-FI-02, R-FI-11), 18 "Configuration values of this chapter".docs/spec/03-data-model.md: 4.7 tablesfiles,file_upload_sessions,file_upload_parts(andfile_thumbnails,message_attachments,file_text_extractsfor what is not built here); 4.2principals(avatar_file_idand its foreign key); 5 "Search vectors" (rowfiles); 6.3 "Lock order";event_log(audience_principal_id, its check); R-DM-13.docs/spec/05-actions.md: rule 1.8; 2.2 "Target of a resource" (rowsfile); 2.6 "Scopes for apps"; 4.8 "Files and search" (file.upload,file.read,file.delete); 6 "Durable event types and receive action" (rowfile.updated).docs/spec/07-permissions.md: 4.1 "Decisions and reasons", 4.2 "Flow" (deferred entries), 4.3 "Visibility", 4.5 (archived channels), 7.4 "Query pattern" rule 1 (exception list), 10.1 "Support only for Alex", 11.2 "Required tests" (AZ-P1 to AZ-P4, AZ-P12).docs/spec/02-architecture.md: 4.3 "Ports, events and sources per module" (rowfiles), 4.4 "Allowed dependencies" (rowfiles), 7.1 "Module register as the source" and 7.2 "go-arch-lint" (vendors only in adapters), 8.2 "Lock order", 8.4 "Idempotency in the Unit of Work", 8.5 "What never happens in a transaction", 9.1 "Chain in the api node" (step 5: body limitfile.max_upload_bytes, deadlinehttp.transfer_timeout), 12.3 "Job catalogue" (rowfiles.scan).docs/spec/04-sync-protocol.md: 2.2 "Envelope" (scope,resource,audience), 7.2 "Flow" (catch-up predicate withaudience_principal_id).docs/spec/06-api.md: 5 "Errors" (closed code list,errors[]pointers), 7 "Idempotency", 10 "Files", 16 "Endpoints" (table "Files").docs/spec/19-security.md: SEC-IN-4, SEC-UP-1, SEC-UP-2, SEC-UP-8, SEC-UP-9, SEC-T08, SEC-T10.docs/spec/14-search.md3.2 ("files: unchanged from 03").docs/api/openapi.yaml: operationscreateUpload,uploadPart,completeUpload,getUpload,abortUpload,getFile(anddeleteFile,downloadFilefor the boundary); schemasUploadCreate,UploadSession,UploadPart,HeaderPair,UploadPartResult,UploadComplete,UploadState,File,Thumbnail,FilePayload,FileUpdatedEvent,Problem(x-problem-codes),FieldError; parametersChannelIdPath,FileIdPath,PartNumberPath,IdempotencyKey; responsesConflict,Gone,PayloadTooLarge,UnsupportedMediaType,TooManyRequests.docs/api/asyncapi.yaml: messageevent.file.updated.- ADRs 0007, 0008, 0009, 0010, 0013, 0015, 0017, 0050, 0052, 0053; for the boundary to later tickets 0033 and 0048.
docs/DECISIONS.md: section 5 "12 Files" (D-FI-01 to D-FI-11), D-W-20 (closed problem codes), D-W-100, section 10 (row formatD-<Ticket>-<n>).docs/operations/upgrade.md4 "Rules for migrations (Expand/Contract)" (M3, M5, M6);docs/operations/configuration.md(keys of Design note 3).docs/observability/signals.md4.4 (bothy.file.upload.size) and "Attributes in spans and logs" (bothy.file.id, never on metrics).docs/security/threat-model.mdT-UP-01, T-AZ-02, T-LG-01;docs/RISKS.mdRK-42.docs/PLAN.md: 3 "Way of working per ticket"; 9 "M3 Platform services" (Goal, Scope, Out of scope, Exit criteria, Test strategy, Red team, Risks) and rows M3-01 to M3-12, M3-15; rows M1-26, M1-55, M1-69, M1-70, M1-71.- Briefs:
M3-01-object-storage-fs.md(portObjectStore, file system driver, key builder), M1-12 (migrations,pgtest), M1-13 (unit of work), M1-14 (jobs: why this ticket inserts no job), M1-16 (appender,AudiencePrincipalID), M1-17 (enable list; it assignsgetUploadto this ticket), M1-18 (route class limits, problem rendering), M1-20 (the idempotency stage runs the handler inside the unit of work), M1-26 (deferred avatar foreign key), M1-28 (Resource, deferredfile.read, visibility table,adapters/fake), M1-53 and M1-55 (chat port), M1-59 (pattern of anadapters/unicodeoutbound port), M1-63 and M1-65 (ResourceFromEvent, delivery of audience events), M1-69, M1-70, M1-71 (canary, leak and contract suites this ticket extends). - Implied dependencies not in the header: M3-01 brings M1-07; M1-69 and M1-70 bring M1-71, M1-68 and M1-65 and through them M1-55 (
chatthreads), which brings M1-53 (channels), M1-28 (authorize), M1-20 (idempotency), M1-18 (HTTP chain), M1-16 (event log), M1-14 (jobs), M1-13 (unit of work) and M1-12 (migrations,pgtest). - Code (all under
services/server/):internal/files(M3-01: port,ObjectStore,adapters/fs, key builder),internal/kernel/{authorize,txn,eventlog,errs,ids,clock,actor,textcheck},internal/authz(domain/requirements.go,domain/visibility.go,adapters/fake),internal/chat(port),internal/platform/pg/pgtest,internal/platform/httpserver,internal/wire(configkeys_*.go,configchecks.go),api/operations.yaml,api/routes,migrations/,sqlc.yaml,architecture/modules.yaml,test/canary,test/contract,test/harness,test/leak.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, permission or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- An upload is a session of parts of fixed size, announced with name, size and SHA-256 before any byte; only mode
direct(12 3.1, R-FI-01, R-FI-02, 12 3.3). createUploadchecksauthorize(file.upload, channel)first (404without read,403with read but withoutwriteorupload), then size againstfile.max_upload_bytes(413 payload_too_large), denied types (415 unsupported_media_typewithreasonfile_type_denied), quotafile.quota_bytes_per_principal(409 conflictwithreasonquota_exceeded), open sessionsfile.upload_max_open_sessions(429withRetry-After) and, for the file system driver,file.fs.min_free_bytes(503 unavailablewithreasonstorage_full) (12 3.1, 3.6, 6.1; openapicreateUpload).- Parts: only the session's principal (else
404); length andContent-Digest(RFC 9530) are checked while reading; a mismatch gives400 validation_failedwitherrors[].codepart_digest_mismatchand stores nothing; the same number with the same digest gives200without new work, with another digest409 conflictwithreasonpart_conflict; parts arrive in parallel and in any order; an expired session gives410 gone(12 3.2, 3.6; openapiuploadPart). getUploadlists the confirmed parts so a client resumes (12 3.2 point 5).- Completion requires all parts, else
409 conflictwithreasonupload_incompleteand the missing numbers indetail; the session becomescompletedand the answer is200with theFile(12 3.4). - Abort deletes the parts; the file becomes
upload_state = 'failed', the sessionaborted(12 2, 12 12). - The file name is cleaned by 12 6.2 and stored as
original_name; the object key holds no name and no channel or space id (12 4.4, SEC-UP-8); the client's MIME type is a hint only andmime_typestaysapplication/octet-streamuntil detection (SEC-UP-2, 12 3.1). - Before attachment only the uploader reads a file (
file.read, 05 2.2); a foreign id and an unknown id answer the same404(12 1 point 2, 12 15, AZ-P4). - Every change of
upload_statewrites the durable eventfile.updatedwith receive actionfile.readand no content beyond theFileschema (12 2, 05 6). - Tables
files,file_upload_sessions,file_upload_partsexactly as 03 4.7,files.search_vectorfrom 03 5, and the foreign keyprincipals.avatar_file_id -> files(id) ON DELETE SET NULL(03principals, deferred by M1-26). - No object store call inside a database transaction (02 8.5).
- The file name is a canary value of SEC-T10: it appears in no log, trace, metric, audit detail, error response or event of another principal.
Scope¶
All paths under services/server/ unless noted.
Create:
migrations/<next>_files_uploads.sqlandmigrations/<next + 1>_identity_principals_avatar_fk_validate.sql(Design note 2), lines inmigrations.sha256throughmake migrations-lock.internal/filesport (root package, next to what M3-01 put there):
// Uploads is the upload half of 12 section 3. The actor comes from kernel/actor in the context.
type Uploads interface {
// Create announces an upload on a channel (12 3.1). Joins the ambient unit of work when one
// exists (keyed request, M1-20), else opens its own.
Create(ctx context.Context, cmd CreateUpload) (UploadSession, error)
// PutPart stores one part in mode direct (12 3.2). Never runs inside a unit of work.
PutPart(ctx context.Context, cmd PutPart) (PartRef, error)
// State returns the confirmed parts of an own session (12 3.2 point 5).
State(ctx context.Context, fileID ids.ID) (UploadState, error)
// Complete closes an own session (12 3.4).
Complete(ctx context.Context, cmd CompleteUpload) (File, error)
// Abort ends an own open session and deletes its parts.
Abort(ctx context.Context, fileID ids.ID) error
}
// Files reads file metadata (12 13, GET /api/v1/files/{id}).
type Files interface {
Get(ctx context.Context, fileID ids.ID) (File, error)
}
// CreateUpload{ChannelID ids.ID; Name string; SizeBytes int64; SHA256 [32]byte; DeclaredMIMEType string}
// PutPart{FileID ids.ID; PartNumber int; Digest [32]byte; ContentLength int64 /* -1 unknown */; Body io.Reader}
// CompleteUpload{FileID ids.ID; Parts []PartRef}; PartRef{PartNumber int; ETag string}
// UploadSession{FileID ids.ID; Mode string; PartSizeBytes int64; PartCount int; ExpiresAt time.Time}
// UploadState{FileID ids.ID; View string /* pending, uploading, complete */; PartSizeBytes int64; Confirmed []PartRef; ExpiresAt time.Time}
// File mirrors the openapi schema File (zero values mean absent).
internal/files/domain/:partplan.go(PlanParts(size, partSize int64) (count int, last int64),PartLength(n, count int, size, partSize int64) int64),name.go(CleanName(nfc string, maxBytes int) string, steps 1 to 4 of Design note 6),digest.go(ParseContentDigest(h string) ([32]byte, error)),session.go(session states, allowed transitions,ViewforUploadState),etag.go(etag of a file system part),ranges.go(missing part numbers as ranges fordetail), plusPartKey(fileID ids.ID, n int)next to M3-01's key builder if M3-01 built none.internal/files/app/:create.go,parts.go,state.go,complete.go,abort.go,get.go,events.go(builder offile.updated, reused by M3-04 to M3-10),partstore.go(interfacePartStorewith thefsands3strategies overObjectStore, Design note 4),ports.go(outbound ports: repository,PartStore,FreeSpace{FreeBytes(ctx) (int64, error)},NameNormalizer{NFC(s string) string},TypeExtensions{Extensions(mediaType string) []string}, channel locator over thechatport),metrics.go.internal/files/adapters/postgres/queries/files.sql,upload_sessions.sql,upload_parts.sql; sqlc output insqlcgen/; the repository; thefilesentry insqlc.yaml.internal/files/adapters/httpapi/uploads.goandfiles.go: strict-server methods of the six operations.internal/files/adapters/unicode:NameNormalizerwithgolang.org/x/text/unicode/norm(BSD-3-Clause).internal/files/adapters/mimetype:TypeExtensionswithgithub.com/gabriel-vasile/mimetype(MIT, the library 12 6.1 names).internal/files/adapters/otelmetrics:bothy.file.upload.size.- In M3-01's
internal/files/adapters/fs:FreeBytes(statfs offile.fs.root), not part ofObjectStore. internal/wire/configkeys_files.go(or extend M3-01's) and the cross-checks ininternal/wire/configchecks.go(Design note 3).- Tests listed below.
Change:
internal/kernel/authorize:Resource.File *FileFactswithtype FileFacts struct { Purpose string; Attached bool }(additive, doc comment).internal/authz: requirement and visibility forfile.readand kindfile(Design note 15); removefile.readfrom the deferred list and its golden file; README.internal/wire: build the files use cases with M3-01'sObjectStore, thePartStorestrategy chosen byfile.storage.driver, the authorizer, unit of work, appender, clock and adapters; register the handlers.api/operations.yaml: moveabortUpload,completeUpload,createUpload,getFile,getUpload,uploadPartfromlatertoenabled(sorted); regenerate (make codegen-check).architecture/modules.yaml: vendor groups forgolang.org/x/textandgithub.com/gabriel-vasile/mimetype(reuse a group of the same import path if one exists),adapter_vendorsoffiles; regenerate.go-arch-lint.yml(make arch,make register-check).go.mod,go.sum: both libraries pinned to a released version;make licensesgreen.docs/api/openapi.yaml,docs/spec/12-files.md,docs/spec/06-api.md,docs/spec/19-security.md(Design note 19).docs/operations/configuration.mdregenerated (make config-reference).test/canary,test/contract,test/leak: the new paths and routes (Tests).internal/files/README.md,internal/authz/README.md,services/server/CHANGELOG.md(Unreleased),docs/DECISIONS.mdsection 10 rowsD-M3-03-1toD-M3-03-20.
This ticket creates no top-level Makefile target.
Out of scope¶
- Port
ObjectStore, file system driver, key schema and its property test, conformance suite: M3-01. S3 driver and its SeaweedFS container: M3-02. - Verify pass (SHA-256, size, type detection, the second denied-type check, clamd), job
files.scanand itsInsertTxincompleteUpload, assembly of file system parts intoorig-1,CompleteMultipartfor S3,upload_statecompleteorfailedfrom the pass, auditmalware_detected,bothy.file.scan*metrics: M3-04. - Download, thumbnails download, fetch tickets,
Content-Dispositionwith bothfilenameparameters,409 file_not_readyand409 file_blocked: M3-05. - Metadata removal, thumbnails and the table
file_thumbnails(File.thumbnailsstays empty here): M3-06. - Attachments,
message_attachments,AttachmentLocator,FileFinalized,MessageCreate.attachment_file_ids, settingFileFacts.Attached: M3-07. - Expiry of open sessions (
expired), cleanup of never attached and failed files,deleteFile, jobsfiles.cleanup_pendingandfiles.purge_objects: M3-08. - Voice messages and transcripts: M3-09. Avatar and logo, read rules of the purposes
avatarandbranding,identity.AvatarFiles: M3-10. Channel export: M3-11. Runner attachments: M3-12. - File text and
file_text_extracts: M3-15. Search over files: M3-14. - Mode
presigned: not planned; it stays off until the proofs of 12 3.3 exist (RK-42). - Status page warning on
storage_full: M7-09. Retention jobs: M7-05. Export and erasure of files (personaldata.Participant, purposegdpr_export): M7-06. Import ingestionIngestFile(12 11): M8 import tickets (createImportFile, M8-03). - Delivery code of the gateway and catch-up (M1-63, M1-65) stays unchanged; this ticket only writes events they can filter.
- Changes to the core contract.
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M3-03-upload-sessions; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(files): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
- Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 or M2 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - The same holds for M3-01: follow the names of its merged code (
ObjectStore, its operations, the key builder, the file system adapter package) and list each difference in the report. - The file system driver runs in tests only with
file.fs.rootin a directory frommktemp -d; never the default path of 12 18. - No real S3 service and no clamd in this ticket: the
s3strategy is tested against an in-memory fake ofObjectStore; SeaweedFS belongs to M3-02, clamd to M3-04. - New Go libraries only pinned to a released version in
go.modandgo.sum, with a license fromquality/licenses/allowed.txt. test/canary(M1-69),test/contractwithtest/harness(M1-71) andtest/leak(M1-70) must exist in the base branch; if one is missing, stop withVerdict: BLOCKED dependency <ticket> missing(M1-69 and M1-70 are in the header, M1-71 is in the dependency closure through both).
Design notes¶
-
Start check.
make checkgreen on the fresh worktree, elseVerdict: BLOCKED base branch red. M3-01 and M1-55 merged (ObjectStore, the file system driver and the tablechannelsexist), elseVerdict: BLOCKED dependency <id> missing. Check the suites of the last Boundaries bullet. -
Layering (02 4.1, 7.2): domain functions are pure and use only
kernel-core; the app layer holds the use cases and outbound ports; vendors (x/text,mimetype, pgx, OpenTelemetry) appear only in adapters. Other modules use only the root package. The actor comes fromkernel/actor, ids are UUIDv7 fromkernel/ids, time comes fromkernel/clockforcreated_at,expires_atand every expiry comparison (never SQLnow()), so tests control time. -
Tables (03 4.7, verbatim).
migrations/<next>_files_uploads.sql(goose, sequential number and slug rules ofmake migrations-lint):fileswith every column, type, default and CHECK of 03 (purposedefault'attachment',upload_state,scan_status,transcript_status,transcript_source),search_vector tsvector GENERATED ALWAYS AS (setweight(to_tsvector('simple', original_name), 'A') || setweight(to_tsvector('simple', coalesce(transcript_text, '')), 'B')) STORED(03 5;STOREDis checked bymake migrations-lint); PKid; FKsuploaded_by -> principals(id) ON DELETE RESTRICT,scope_channel_id -> channels(id) ON DELETE RESTRICT; table CHECK(purpose = 'attachment') = (scope_channel_id IS NOT NULL); UNIQUEobject_key; indexes(uploaded_by, created_at DESC),(scan_status) WHERE scan_status = 'pending',(upload_state, created_at) WHERE upload_state = 'pending', GIN(search_vector).file_upload_sessions: columns, defaults and CHECKsmode,stateof 03; PKfile_id; FKfile_id -> files(id) ON DELETE CASCADE; index(expires_at) WHERE state = 'open'.file_upload_parts: columns of 03; PK(file_id, part_number); FKfile_id -> file_upload_sessions(file_id) ON DELETE CASCADE.ALTER TABLE principals ADD CONSTRAINT principals_avatar_file_id_fkey FOREIGN KEY (avatar_file_id) REFERENCES files(id) ON DELETE SET NULL NOT VALID.
migrations/<next + 1>_identity_principals_avatar_fk_validate.sql:ALTER TABLE principals VALIDATE CONSTRAINT principals_avatar_file_id_fkey(upgrade.md M5: a new constraint on an existing table is addedNOT VALIDand validated in a later migration; M6migrate.lock_timeoutapplies through the runner of M1-12). The new tables get their constraints and indexes at creation because they are empty. Add no column, CHECK or index that 03 does not name; the range ofpart_number(from 1 topart_count) is an application rule. Retention classes (contentforfiles,transientfor sessions and parts) go into the module README; the jobs are M7-05. -
Configuration (12 18, 03 9; names in
docs/operations/configuration.md). Register in the schema what M1-18 and M3-01 did not:file.fs.min_free_bytes,file.upload_part_bytes,file.upload_session_ttl,file.upload_max_open_sessions,file.quota_bytes_per_principal,file.name_max_bytes,file.denied_mime_types,file.s3.presigned_upload; reusefile.max_upload_bytes,file.storage.driver,file.fs.root. Cross-checks ininternal/wire/configchecks.go(they run at start and inbothy config check; messages name keys, never values):upload_part_plan: the part count of a file offile.max_upload_byteswith parts offile.upload_part_bytesstays under the S3 multipart part-count limit (12 3.1, source Q1). The limit is a protocol constant with a comment citing 12 3.1 Q1, not a setting.upload_part_size_s3(Ruling): withfile.storage.driver = s3,file.upload_part_bytesis not below the S3 minimum part size (12 3.1, Q1); otherwise every upload with more than one part would fail atCompleteMultipart.presigned_mode_unavailable(Ruling):file.s3.presigned_upload = truefails, because the mode is not built and stays off until the proofs of 12 3.3 exist (R-FI-02, RK-42). Every session answersmode: direct.
-
Part storage per driver (Ruling for docs/DECISIONS.md). M3-01's
ObjectStorehas exactly the operations of 12 4.1. The app uses one interfacePartStorewith two strategies, chosen ininternal/wirefromfile.storage.driver:fs(12 3.2 last paragraph, 4.4): partnis the objectuploads/<file_id>/<n>, written withPut. Completion touches no object; M3-04's verify pass concatenates the parts intof/<aa>/<bb>/<file_id>/orig-1and deletes them (12 3.4). Discard (abort) callsDeletefor every key from part 1 topart_count; a missing object is not an error (12 4.1). The etag of a file system part is the quoted lowercase hex SHA-256 of the part, derived fromfile_upload_parts.sha256;file_upload_parts.etagstays NULL (03: "ETag of the S3 part"). If M3-01'sPutrefuses an existing key, delete the unconfirmed part object first (only when no row exists for that number).s3(12 3.2 last paragraph): partnis multipart partnof the final object key.CreateMultipartruns lazily at the first part, outside any transaction; store its id withUPDATE file_upload_sessions SET storage_upload_id = $id WHERE file_id = $f AND storage_upload_id IS NULL AND state = 'open'; a request that loses this race callsAbortMultipartfor its own id and uses the stored one.UploadPartreturns the ETag kept infile_upload_parts.etag. Discard callsAbortMultipartwhen an id is stored.CompleteMultipartdoes not run incompleteUpload; M3-04's verify pass runs it before reading.
Reason:
createUploadandcompleteUploadacceptIdempotency-Key, the idempotency stage of M1-20 runs their handlers inside the unit of work (02 8.4), and 02 8.5 forbids object store calls inside a transaction; 12 3.4 step 2 ("S3:CompleteMultipartUpload" in the request) and 12 3.2 are updated (Design note 19). The test fake ofObjectStorefails every call made whiletxn.InUnit(ctx)is true. -
createUpload(POST /api/v1/channels/{channel_id}/uploads, actionfile.upload,rl.upload), in this order (Ruling: 12 3.1 numbers authorize, size, quota, sessions and space; 6.1 gives the early type check no place; it comes right after the size because it needs no database): a. Schema validation by the generated layer (M1-18):name,size_bytesat least 1,sha256lowercase hex (after Design note 19),declared_mime_typeoptional. b.authorize(actor, file.upload, Resource{Kind: channel, ID: channel_id}): no read gives404; read withoutwritegives403 forbiddenwithreasonlevel_insufficient, withoutuploadcapability_missing; an archived or read-only channel gives403 channel_read_only(05 rule 1.8, 07 4.5). Apps needfiles:write(05 2.6). c.size_bytesabovefile.max_upload_bytes:413 payload_too_large,detailnames the key (SEC-UP-1). d. Clean the name (Design note 6). Denied type (Design note 7):415 unsupported_media_typewithreasonfile_type_denied. e. Quota, open sessions, free space (Design note 8). f. Insert, in the ambient unit of work when the request is keyed, else in an own one:files(id;object_key= final key of revision 1 from M3-01's key builder, without name, channel or space id;sha256andsize_bytes= declared values;mime_type = 'application/octet-stream';declared_mime_type= media type without parameters in lower case, NULL when absent;original_name;uploaded_by= actor;purpose = 'attachment';scope_channel_id= channel; both statespending) andfile_upload_sessions(mode = 'direct',state = 'open',declared_size_bytes,declared_sha256,part_size_bytes = file.upload_part_bytes,part_countfromPlanParts,created_at,expires_at = created_at + file.upload_session_ttl). g. Answer201UploadSession:file_id,mode: direct,part_size_bytes,partswith one entry per number (url/api/v1/uploads/<file_id>/parts/<n>,headers[{name: Content-Type, value: application/octet-stream}]),expires_at. No event and no job: neitherupload_statenorscan_statuschanged (12 2).Idempotency-Keyis handled by M1-20. -
File name (12 6.2),
original_name: the app callsNameNormalizer.NFC(adapterinternal/files/adapters/unicode, because the architecture gate allows vendors only in adapters, 02 7.2; same pattern as M1-59) and thendomain.CleanName(nfc, file.name_max_bytes):- Keep only the last path segment: split at
/and\. - Remove control characters, bidi control characters (U+061C, U+200E, U+200F, U+202A to U+202E, U+2066 to U+2069) and zero-width characters (U+200B to U+200D, U+2060, U+FEFF); reuse the character classes of
kernel/textcheckwhere it exposes them, so the sets stay one source. Trim white space at both ends. - An empty rest,
.or..becomesfile(Ruling: 12 6.2 calls them invalid; the name is only a label, and a400for an already cleaned name would give the client nothing to correct). - Longer than
file.name_max_bytesUTF-8 bytes: keep the extension (the last.and what follows, when that part alone fits), cut the stem at a rune boundary so that stem and extension fit.
Names are cleaned, never rejected (12 6.2, 12 15); SEC-IN-4 is aligned (Design note 19). Never log the raw or the cleaned name.
- Keep only the last path segment: split at
-
Denied types, early check (12 6.1): with an empty
file.denied_mime_typesnothing is checked. Otherwise the upload is denied when (a) the declared media type, lower case and without parameters (mime.ParseMediaType; an unparsable value gives400 validation_failedwith pointer/declared_mime_typeand codeinvalid), equals a denied type, or (b) the extension of the cleaned name equals, ignoring case, an extension of a denied type. Ruling: extensions come fromgithub.com/gabriel-vasile/mimetype(mimetype.Lookupand the extensions of the found type) through the portTypeExtensions, because the standard library'smime.ExtensionsByTypereads tables of the operating system and differs between hosts. The second check against the detected type is M3-04. -
Quota, open sessions, free space:
- Serialize per principal (Ruling): before the two counts take
pg_advisory_xact_lockon a key derived from a files-specific namespace and the principal id; without it two parallel creates of one principal both pass. It is a domain lock in step 2 of 03 6.3. - Quota, only when
file.quota_bytes_per_principalis not0: the sum ofsize_bytesof the principal's files withdeleted_at IS NULL AND upload_state <> 'failed'plus the new size may not exceed it, else409 conflictwithreasonquota_exceeded. Ruling: failed files count as deleted, because their objects are removed at once (12 12) and an aborted upload must not block the quota untilfile.failed_row_ttl. The index(uploaded_by, created_at DESC)serves the sum. - Open sessions: if the principal has at least
file.upload_max_open_sessionssessions withstate = 'open'andexpires_atin the future, answer429 rate_limitedwithRetry-After= time until the earliest of theirexpires_at, rounded up to whole seconds and never zero;detailnamesfile.upload_max_open_sessions(Ruling: 12 3.6 names no code, andrate_limitedis the only code with status429). - Free space, driver
fsonly:FreeSpace.FreeBytes()(statfs offile.fs.root; a local system call, not a network call) minussize_bytesbelowfile.fs.min_free_bytesgives503 unavailablewithreasonstorage_fullandRetry-Afterfromhttp.unavailable_retry_after; log one warning with neither name nor channel.
- Serialize per principal (Ruling): before the two counts take
-
uploadPart(PUT /api/v1/uploads/{file_id}/parts/{part_number}, actionfile.upload,rl.upload, noIdempotency-Key, never inside a unit of work): a. Load session and file byfile_idanduploaded_by = actor. No row, orfiles.deleted_atset:404with exactly the body and headers of an unknown id (12 3.2 point 1). b.authorize(actor, file.upload, Resource{Kind: channel, ID: scope_channel_id}):not_foundtriggers Design note 14 and answers404;forbiddenanswers403. c. Sessionabortedorexpired, oropenwithexpires_atreached:410 gone. d.part_numberabovepart_count:400 validation_failedwith pointer#path/part_numberand codeout_of_range. e.Content-Digest(RFC 9530 dictionary): the membersha-256must exist and hold the bytes of one SHA-256 digest; other members are ignored. Missing or malformed:400 validation_failedwith pointer#header/Content-Digestand codeinvalid. f. A row for this number exists (12 3.2 point 3): the same digest answers200withUploadPartResultand reads no body; another digest answers409 conflictwithreasonpart_conflict. This also holds after completion, so a client can repeat a part whose answer was lost. g. Expected length L =PartLength(the last part is the rest). AContent-Lengthother than L fails at once, before any byte is read, with the length error below. Otherwise wrap the body in a reader that hashes and counts, stops after one byte more than L, and at the end returns an error when the count is not L or the digest differs; give it toPartStore.Putwith size L. The error makes the store keep nothing (file system: the temporary file is never renamed, 12 4.2; S3: the part request fails). Both cases answer400 validation_failedwitherrors[].codepart_digest_mismatch(pointer#header/Content-Digestfor a wrong digest, empty pointer for a wrong length; Ruling) and store nothing (12 3.2 point 2). If M3-01'sPutcan leave an object after a reader error, stop withVerdict: BLOCKED(12 4.2: "A reader never sees half an object"). h. Short unit of work:SELECT ... FOR SHAREon the session row. Nowabortedorexpired: answer410 goneand delete the object just stored after the commit (the session's parts are discarded anyway).open:INSERT INTO file_upload_parts ... ON CONFLICT (file_id, part_number) DO NOTHING RETURNING; on conflict compare digests as in f.completed: a completed session has a row for every number, so compare with that row as in f and delete nothing (with the file system driver the key is shared with the confirmed part). i. Answer200UploadPartResult {part_number, etag}. No event.Body limit
file.max_upload_bytesand deadlinehttp.transfer_timeoutcome from the route class of M1-18. Parts may arrive in parallel and in any order (12 3.2 point 4). -
The same new part number twice in parallel (Ruling): both requests stream; the first committed row wins; the other answers
200with the same digest or409 conflictwithreasonpart_conflictwith another one. Because 12 4.4 fixes the part key, the later write can replace the bytes of the winner; the verify pass of M3-04 then fails the file withchecksum_mismatch(12 3.4: the total checksum catches errors of assembly), and with S3 the stored ETag no longer matches atCompleteMultipart. No wrong byte is ever delivered. A confirmed part is never written again (note 9 f); "Objects are never overwritten" (12 4.4) concerns the final objects underf/. -
getUpload(GET /api/v1/uploads/{file_id}, actionfile.upload,rl.upload): lookup and authorize as note 9 a and b.aborted,expiredorexpires_atreached:410 gone. AnswerUploadState(Ruling, the schema has its own enum):upload_statepending(open, no confirmed part),uploading(open, at least one),complete(sessioncompleted);part_size_bytes;completed_partsascending with etags (note 4);expires_at. -
completeUpload(POST /api/v1/uploads/{file_id}/complete, actionfile.upload,rl.upload,Idempotency-Key): a. In the unit of work: the actor's sessionFOR UPDATE; no row:404. b. Authorize as note 9 b. c. Alreadycompleted:200with the currentFile, nothing changes (Ruling).aborted,expiredorexpires_atreached:410 gone. d.UploadComplete.parts(Ruling; in modedirectthe confirmed rows are the truth): a duplicate number gives400 validation_failed(pointer/parts, codeduplicate); a number outside 1 topart_countgives400 validation_failed(pointer/parts, codeout_of_range); the list may name fewer parts than exist. e. Any number from 1 topart_countwithout a confirmed row:409 conflictwithreasonupload_incomplete;detaillists the missing numbers ascending as ranges (for example4-6,9) and nothing else (12 3.4 point 1). The session stays open. f. A listedetagthat differs from the confirmed one:409 conflictwithreasonpart_conflict. g. The sum of confirmed part sizes equalsdeclared_size_bytes(holds by construction; otherwise an internal error). h.state = 'completed',completed_at. No object store call (note 4), no event (the states of the file do not change), no job:jobs.Inserter.InsertTxof M1-14 rejects a kind without a registered handler, so M3-04 addsfiles.scanhere together with its handler (02 12.3). i.200withFile(upload_state: pending,scan_status: pending). Recordbothy.file.upload.size(note 17). -
abortUpload(DELETE /api/v1/uploads/{file_id}, actionfile.delete,rl.write, no key): a. Load as note 9 a. b.authorize(actor, file.delete, Resource{Kind: file, ID: file_id, ChannelID: scope_channel_id, OwnerID: uploaded_by, File: &FileFacts{Purpose: "attachment", Attached: false}})(05 4.8:self or manage; visibility as note 15). c.abortedorexpired:204, nothing changes (06 7: DELETE of a deleted resource).completed:409 invalid_state_transition(Ruling; openapi gets the response, note 19; removing a completed, unattached file isdeleteFile, M3-08). d.open(also afterexpires_at): one unit of work locks the session rowFOR UPDATE(so it serializes with note 9 h and note 12 a), sets the sessionabortedand the fileupload_state = 'failed', deletes itsfile_upload_partsrows and appendsfile.updated(note 16). After the commitPartStore.Discard. A storage error after the commit is logged with the file id only and the answer stays204; leftovers belong to the cleanup of failed files (12 12, M3-08). e.204. -
Permissions revoked during an upload (12 3.6, Ruling): when
authorizeanswersnot_foundfor an existing own session inuploadPart,getUploadorabortUpload(requests withoutIdempotency-Key, so no ambient unit of work), the use case aborts the open session as in note 13 d in its own unit of work and then answers404. IncompleteUploadthe idempotency stage rolls back every final error (M1-20), so the abort cannot persist there; the session is aborted by the next request without key or ends afterfile.upload_session_ttl(M3-08). A403aborts nothing. -
getFileand the rule offile.read:getFile(GET /api/v1/files/{file_id},rl.read): load by id withdeleted_at IS NULL(no row:404);authorize(actor, file.read, Resource{Kind: file, ID, ChannelID: scope_channel_id, OwnerID: uploaded_by, File: &FileFacts{Purpose, Attached: false}}); answerFilewith the stored fields,scope_channel_id,thumbnails: [](M3-06 fills it), no JSONnull(ADR 0053), andsha256as lowercase hex only whenupload_state = 'complete'(Ruling: the schema says "of the stored bytes"; before the verify pass only the declared value exists). Metadata is readable in every state; "only after the scan" in 05file.readgoverns bytes, which M3-05 serves.internal/authz: replace the deferred entry offile.readwithLevel(read)(05 "read") and drop it from the golden deferred list (AZ-P3 stays green). Visibility of kindfile(05 2.2, 07 4.3), Ruling: purposeattachmentand not attached: visible only when the principal equalsOwnerIDand has at leastreadon the path ofChannelID(DM channels: participant); attached: at leastreadon the path of the hints (the message's thread and channel); purposesavatar,branding,gdpr_export: not visible (no_grant) until M3-10 and M7-06 replace this, fail closed.File == nilis allowed only in filter or delivery mode (authorize.Filter(ctx), the delivery mode of M1-65), where the event scope is the path (ResourceFromEvent, M1-63); in request mode it is an internal error, so no caller widens access by omitting the facts.file.deletekeepsAny(Self, Cap(manage))with the same visibility;file.uploadstays on the channel. Document the file facts in the authz README.
-
Event
file.updated(12 2, 05 6), here written only by the abort (notes 13 and 14):resource {kind: file, id}withoutversion(Filehas none),scope {space_id, channel_id}of the scope channel (ids from thechatport without a permission check; if M1-53's port offers no such read, add one additively next toThreads.Info), receive actionfile.read, payloadFilePayload {file}that validates againstFileUpdatedEvent, actor of the request. Before attachment the event carriesAudiencePrincipalID= uploader (Ruling):ResourceFromEvent(M1-63) carries neither uploader nor attachment, so without an audience the hub and the catch-up would deliver the uploader's file name to every reader of the channel, against 05 2.2 ("then only the uploader reads it"); 03event_logallows an audience with a receive action other thanself. M3-07 writes attached files without audience in the scope of the message.createUploadandcompleteUploadwrite no event (Ruling). Keep the builder ininternal/files/app/events.gofor M3-04 to M3-10. -
Audit, logs, metrics:
file.uploadis audited only on a malware finding (M3-04),file.readandfile.deletenever (05 4.8); denials go throughauthorizeas M1-28 built it. Logs and spans carry at mostbothy.file.id; never name, digest, declared type or channel name (12 14, SEC-LOG rules of 19, signals.md).bothy.file.upload.size(histogram, unitBy, no attributes, source api; signals.md 4.4) is recorded once per successfulcompleteUploadwithdeclared_size_bytesthroughinternal/files/app/metrics.goandadapters/otelmetrics, the pattern of M1-68. -
Queries (07 7.4 rule 1, AZ-P12): every reading query is keyed by
file_id(resolution forauthorize, loading a checked resource) or byuploaded_by(quota and open sessions of the actor); none lists content across channels. Add each query with this reason to the exception list of the lint of M1-70. Writes touch only rows of a session the use case just authorized. SQL names only tables offiles(02 7.3); the migration onprincipalsis the deferred foreign key that 03 and M1-26 assign to this ticket. -
Specification and API (spec first, rule 10; nothing in 00, 03, 04 or 05 changes):
docs/api/openapi.yaml:UploadCreate.requiredaddssha256withpattern: '^[0-9a-f]{64}$'and the description "Lowercase hex SHA-256 of the whole file; the verify pass checks it", and thecreateUploadexample gets a value;uploadPartgets a required header parameterContent-Digest(RFC 9530, membersha-256);completeUploaddescription: size and SHA-256 are checked by the verify pass,partsis cross-checked as note 12;getUploaddescription: the mapping of note 11;abortUploadgets a409response (Conflict, codeinvalid_state_transition) and a description of note 13. Thenmake codegen-check,make api-lint,make api-checks, andmake api-tablesif a generated block of 06 changes.docs/spec/12-files.md: 2 (audience before attachment); 3.1 (sha256required as lowercase hex,declared_mime_typeoptional as in openapi, check order with the type check, quota without failed files, the429code,Idempotency-Keyas the openapi parameter); 3.2 (part objects for the file system, lazyCreateMultipartfor S3, etag rule); 3.4 step 2 (S3CompleteMultipartin the verify pass); 3.5 (parts and completion answer200, as openapi); 6.2 (.and..becomefile); 3.4 and 12 use the job names of 02 12.3 (files.scan,files.purge_objectsinstead offile_verify,file_delete).docs/spec/06-api.md10 point 2:completeleads to the check of size and SHA-256 (no longer "optionally").docs/spec/19-security.mdSEC-IN-4: file names are cleaned by 12 6.2 instead of rejected.
-
Rulings for
docs/DECISIONS.mdsection 10, one row each with date, decision, reason and place: D-M3-03-1sha256required and lowercase hex (note 19); -2 part storage per driver (note 4); -3 etag of a file system part (note 4); -4 check order ofcreateUpload(note 5); -5 early type check with extensions frommimetype(note 7); -6 quota without failed files (note 8); -7 advisory lock per principal (note 8); -8429 rate_limitedwithRetry-Afteruntil the earliest expiry (note 8); -9 name cleaning details (note 6); -10Content-Digestparsing, pointers and codes (note 9); -11 answers per session state (notes 9, 11, 12, 13); -12 cross-check ofUploadComplete.parts(note 12); -13 mapping ofUploadState(note 11); -14 parallel writes of one new part (note 10); -15 abort after lost read (note 14); -16 audience offile.updatedand no event on create and complete (note 16); -17 visibility of kindfileandFile == nil(note 15); -18File.sha256only when complete (note 15); -19 start checks (note 3); -20 discard after the commit with cleanup by M3-08 (note 13).
Tests (write them first)¶
Unit:
internal/files/domain:TestPlanPartsandTestPartLength: size equal to the part size, one byte more, smaller, an exact multiple; count rounded up; the last part is the rest.TestCleanName: U+202E inside a name,../../x.pdf,a\b.txt, control and zero-width characters, a decomposed input stays composed after the adapter,.,.., empty and blank givefile; overfile.name_max_bytes(test config) keeps the extension and never splits a rune.FuzzCleanName: output is valid UTF-8, has no control, bidi or zero-width character, no/or\, is never empty, fits the limit and is stable when cleaned again.TestParseContentDigest(valid, other members ignored, missing member, wrong length, bad base64, wrong form) andFuzzParseContentDigest(never panics; accepts only one SHA-256 digest).TestSessionTransitions(open to completed, open to aborted; completed to aborted and aborted to completed rejected) andTestUploadStateView.TestFSPartETagandTestMissingRanges.
internal/files/app(fakes: repository,ObjectStore, authorizer frominternal/authz/adapters/fakeor a kernel-level fake, clock):TestCreateUploadCheckOrder: a table where several checks fail at once; the first in the order of note 5 decides.TestS3PartStrategy: the first part callsCreateMultipartonce; two parallel first parts keep one id and abort the other;UploadPartgets the stored id and its ETag is stored; abort callsAbortMultipart; completion calls nothing on the store.TestNoObjectStoreCallInUnitOfWork: the fake fails on any call whiletxn.InUnit(ctx)is true; create, parts, complete and abort pass with both strategies.TestDeniedTypeEarlyCheck: declared type with parameters and other case, extension match, unknown type, empty list.
internal/authz:TestFileVisibility(uploader with read on the scope channel allows; uploader after losing read getsnot_found; Sam with read on the channel and Robin getnot_foundfor Alex's unattached file; the owner getsnot_found; attached facts with read on the message's channel allow; purposesavatar,branding,gdpr_exportgivenot_found;File == nilis an internal error in request mode and resolves the hint path in filter mode),TestFileDeleteVisibility, and the updatedTestDeferredActionsGolden.internal/wire:TestFilesConfigChecksforupload_part_plan,upload_part_size_s3andpresigned_mode_unavailable.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain with the response validation of M1-71, M3-01's file system driver rooted in a mktemp -d directory; world after 07 10.1: space Support with channel Allgemein, Alex with trigger and upload on Support, Sam with write but without upload on Support, Robin without a grant on Support, one owner):
TestMigrationFilesTables: columns, types, defaults and CHECKs of the three tables as in 03;search_vectoris a stored generated column with its GIN index; uniqueobject_key; FKs with theirON DELETEactions;principals_avatar_file_id_fkeyexists and is validated; a secondmigrate upchanges nothing; afilesrow withpurpose = 'avatar'and a scope channel fails the table check.TestCreateUpload_Session:201,modedirect,part_size_bytesfrom config, onepartsentry per number with the documented URL and header,expires_at= clock plusfile.upload_session_ttl; rows as note 5 f;object_keyfollows 12 4.4 and contains neither the name nor the channel or space id; noevent_logrow.TestCreateUpload_Rights: Robin404; Sam403 forbiddenwithreasoncapability_missing; an archived channel403 channel_read_only.TestCreateUpload_Limits: overfile.max_upload_bytes413 payload_too_largewith the key indetail; quota exceeded409 conflictwithreasonquota_exceeded, and an aborted upload frees the quota; atfile.upload_max_open_sessions429withRetry-Afteruntil the earliest expiry; free space below the reserve (fakeFreeSpace)503 unavailablewithreasonstorage_full; denied type by declared type and by extension415 unsupported_media_typewithreasonfile_type_denied.TestCreateUpload_ParallelAtLimit: many parallel creates of Alex one below the open-session limit: exactly one succeeds; the same for the quota.TestCreateUpload_IdempotencyKey: a repeat returns the stored body withIdempotent-Replay: trueand creates no second file.TestCreateUpload_NameCleaned(12 15 row "File name with U+202E,../, control characters"): the storedoriginal_nameis cleaned;getFileshows the cleaned name.TestUploadPart_SameDigest200(plan): the same part twice with the same digest answers200twice, one row, the object is written once (fake call count or file modification time).TestUploadPart_OtherDigest409(plan, 12 15 row "Part sent twice, identical or different"): another digest for a confirmed number answers409 conflictwithreasonpart_conflict; the stored part is unchanged.TestUploadPart_WrongLengthRejected(plan "wrong length rejected"): a shorter and a longer body, for a middle part and the last part, and a wrongContent-Length, each400 validation_failedwitherrors[].codepart_digest_mismatch; no row; no object at the part key.TestUploadPart_WrongDigestRejected: same result for a correct length with a wrong digest.TestUploadPart_HeaderAndNumber: missing or malformedContent-Digestand a part number abovepart_countgive400 validation_failedwith the pointers of note 9.TestUploadPart_ForeignAndUnknown404: Sam's part for Alex's session and a part for an unknown id answer the same status, body and headers.TestUploadPart_ParallelAnyOrder: all parts at once in reverse order; all confirmed;getUploadlists them.TestUploadPart_Expired410andTestUploadPart_Aborted410(clock moved pastexpires_at; session aborted).TestGetUpload_Resume:pending,uploading,completewith ascendingcompleted_partsand etags; foreign and unknown ids the same404; an aborted session410 gone.TestCompleteUpload_MissingParts409(plan "missing parts409"):409 conflictwithreasonupload_incomplete,detailnames exactly the missing numbers as ranges; the session stays open.TestCompleteUpload_Completes:200Filewith both statespending; sessioncompletedwithcompleted_at; a second complete without the same key answers200and changes nothing; no job and no event row.TestCompleteUpload_PartsCrossCheck: duplicate and out-of-range numbers400 validation_failed; a wrong etag409 conflictwithreasonpart_conflict.TestAbortUpload_DeletesParts(plan "abort deletes the parts"): after204every part key reports missing throughObjectStore.Headand no regular file remains underuploads/<file_id>in the temporary root; part rows gone; sessionaborted; filefailed; exactly onefile.updatedwithaudience_principal_id= Alex, receive actionfile.read, scope of Support/Allgemein, payload valid againstFileUpdatedEvent.TestAbortUpload_States: a second abort204without a new event; abort of a completed session409 invalid_state_transition; Sam and Robin404.TestLostReadAbortsSession(12 3.6): revoke Alex's grant on Support; the nextuploadPartanswers404, the session isaborted, its parts are deleted.TestGetFile_UploaderOnly(12 15 row "Someone else's file ID and a non-existent ID"): Alex gets200withscope_channel_id, emptythumbnails, nosha256while pending, no JSONnull; Sam (read on the channel), Robin and the owner get the same404as for an unknown id; a row withdeleted_atset answers404.TestPutFailingReaderLeavesNothing: M3-01's file systemPutwith a reader that fails at its end leaves no object (precondition of note 9 g).TestUploadSizeMetric: one histogram point per completion, without attributes (in-process exporter).
Suites of other tickets, extended here:
- SEC-T10,
test/canary(plan "the canary scenario contains a file name"): add the path "file name": Alex creates an upload whose name carries the run's canary value, sends a part, completes, reads it withgetFile, aborts a second upload with the same name and provokes a413and a400with it; the canary appears in no log, trace, metric, audit detail or error response and in no event delivered to Sam, Robin or the owner. - G7 and G8,
test/contract(M1-71, SEC-T06): every new operation is called; add fixtures or hooks souploadPart,getUpload,completeUploadandabortUploadreach a real session with a matchingContent-Digest; no unexpected5xx; every response validates. - AZ-P4,
test/leak(M1-70):getFileandgetUploadanswer a foreign and an unknown id alike in status, body, headers and median time withinauthz.not_found_timing_tolerance; AZ-P12 exception entries for the files queries (note 18). - AZ-P1 (route count includes the six routes), AZ-P2 (
file.updatedis a registered event type) and AZ-P3 (file.readno longer deferred) stay green.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/files/... ./internal/authz/... ./internal/kernel/... ./internal/wire/... ./migrations/...cd services/server && go test -race -count=1 ./test/canary/... ./test/leak/...make migrations-lock(once after creating the migrations),make migrations-lint,make codegen-check,make arch,make register-check,make api-lint,make api-checks,make config-doc-check,make licenses,make docsmake check
Acceptance criteria¶
- Migrations create
files,file_upload_sessionsandfile_upload_partsexactly as 03 4.7 withfiles.search_vectorfrom 03 5, and addprincipals.avatar_file_id -> files(id) ON DELETE SET NULLasNOT VALIDplus a laterVALIDATE. -
createUpload,uploadPart,getUpload,completeUpload,abortUploadandgetFileare enabled and registered; AZ-P1 passes;file.readis no longer deferred. -
createUploadchecks permissions, size, type, quota, open sessions and free space in the order of Design note 5 with the codes of 12 3.6; the name is cleaned and the object key holds no name, channel or space id. - A part with the same digest answers
200; another digest answers409 conflictwithreasonpart_conflict. - A part of the wrong length (or with a wrong digest) is rejected with
400 validation_failedanderrors[].codepart_digest_mismatch, and nothing is stored. - Completion with missing parts answers
409 conflictwithreasonupload_incompleteand names the missing numbers. - Abort deletes the parts, marks the session
abortedand the filefailed, and writes onefile.updatedfor the uploader only. - Only the uploader reads an unattached file; foreign and unknown ids answer the same
404(AZ-P4 forgetFileandgetUpload). - No object store call happens inside a database transaction.
- The canary scenario contains a file name and finds it nowhere it must not be (SEC-T10).
- openapi, 12, 06 and 19 are updated as Design note 19 says; the rulings
D-M3-03-1toD-M3-03-20are indocs/DECISIONS.md. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
files(status, port, tables with retention classes, actions, event, part strategies, outbound ports, query exceptions of note 18) andauthz(file facts and visibility of note 15) updated. - Rulings of note 20 recorded in
docs/DECISIONS.md(one row each with reason). services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.