M3-10: Avatar and logo¶
Status: ready | Depends on: M3-06 | Milestone: M3
Goal¶
A person sets and removes the own avatar (setMyAvatar, deleteMyAvatar), an owner with fresh step-up uploads and removes the instance logo (uploadBrandingLogo, deleteBrandingLogo), and anyone, also before sign-in, loads the logo with getBrandingLogo. Both images are files of the module files with files.purpose avatar or branding: they run through the same verification pass, malware scan, metadata removal and thumbnails as every attachment (M3-03, M3-04, M3-06), no byte leaves the server before the file is deliverable, and no stored original or variant carries location, device or text metadata. The read rule follows the purpose (05 2.2): every active signed-in principal reads an avatar file through file.read; the logo is public only through getBrandingLogo with instance.discovery.read, and no other route hands an anonymous caller any file. This is the part "avatar and logo" of the M3 scope, and it closes the branding path that M1-46 left open (branding.logo_file_id is written only here).
Read first¶
docs/PLAN.md: section 3 'Way of working per ticket'; section 9 'M3 Platform services' with Goal, Scope, Out of scope, Exit criteria (SEC-T08), Test strategy, Red team ('metadata in images'), Risks, and the rows M3-01, M3-03, M3-04, M3-05, M3-06, M3-07, M3-08 and M3-10; section 7 rows M1-26, M1-28, M1-31, M1-46; row M6-09 (later consumer of the logo operations); decisions P-04, P-09, P-10, P-11 in section 19.docs/spec/12-files.md: 1 'Goals and limits'; 2 'States of a file' (deliverable, final,file.updated); 3.1 'Creating an upload session' (order of checks), 3.2, 3.4 'Completion and verification pass', 3.6 'Error cases', 3.7 'Purpose of a file'; 4.1 'Port', 4.4 'Key schema'; 6.1 'Type' (two-step type check,file_type_denied), 6.2 'File names'; 7.1 'Rule', 7.2 'Protection against decompression bombs', 7.3 'Thumbnails'; 9.3 'Response'; 12 'Retention, deletion, deduplication'; 13 'Endpoints and actions' (last paragraph); 14 'Observability'; 15 'Tests'; 16 R-FI-05, R-FI-07; 18 for the keys.docs/spec/05-actions.md(core contract, read only): 1 'Binding rules' rules 1 and 2; 2.1, 2.2 'Target of a resource' (the fourfilerows by purpose), 2.4, 2.5 (pending principal and device), 2.6 (scopefiles:read); rowsinstance.discovery.read(4.1),instance.branding.update(4.2),self.profile.update(4.3),file.uploadandfile.read(4.8),agent.update(4.10); section 6 rowsfile.updated,user.updated,instance.updated.docs/spec/03-data-model.md(core contract, read only): 4.1instance_settings; 4.2principals(avatar_file_id, foreign keyON DELETE SET NULL); 4.7files(purposeand the check(purpose = 'attachment') = (scope_channel_id IS NOT NULL)),file_thumbnails,file_upload_sessions,file_upload_parts; 4.10event_log(audience_principal_id,receive_action, the check onself); 6.3 'Lock order'.docs/spec/04-sync-protocol.md: 2.2 'Envelope' (audience, empty scope, no JSONnull).docs/spec/06-api.md: 2 'Basic rules' (rows 'Media types', 'Caching', 'Unknown values'); 3 (anonymous operations run throughauthorizeasanonymous); 5 'Errors' (closed code list,reason,errors[].codeexamplesrequired); 7 'Idempotency' (PUT and DELETE); 8 'Concurrency' points 1 to 3; 10 'Files' point 4; 16 'Endpoints' (rowsgetBrandingLogo,setMyAvatar,deleteMyAvatar,uploadBrandingLogo,deleteBrandingLogo).docs/spec/07-permissions.md: 4.2 'Flow' (anonymous principals pass onlypublicactions; visibility before requirement before step-up), 4.3 'Visibility', SR5, 11 tests AZ-P1, AZ-P4, AZ-P13, AZ-M35, AZ-M36.docs/spec/02-architecture.md: 4.3 'Ports, events and sources per module' (identity declaresAvatarFiles, files implements it), 4.4 'Allowed dependencies', 4.6 'Actions per module', 5 'Layers within a module', 7.3 'Other checks in the gate' (row 'Routes and ops': the handler lives in the module that owns the action), 8 'Transactions and Unit of Work' (8.1, 8.2, 8.5), 9.1 step 5 (body limit per route class), 12.3 'Job catalogue' (files.scan,files.thumbnail,files.cleanup_pending,files.purge_objects).docs/spec/01-product.md: 12 'Branding per instance' (the app falls back to the product mark while no logo exists).docs/spec/19-security.md: SEC-HDR-1, SEC-HDR-2, SEC-HDR-4, SEC-CORS-2, SEC-UP-1 to SEC-UP-11 (SEC-UP-5: no parser for untrusted formats in the API process), SEC-T03, SEC-T08, R-SE-06.docs/spec/20-privacy.md: rowprincipals(avatar is personal data of the account class).docs/api/openapi.yaml: operationssetMyAvatar,deleteMyAvatar,uploadBrandingLogo,getBrandingLogo,deleteBrandingLogo,updateBranding,getDiscovery,getInstance,getFile,downloadFile,downloadThumbnail; schemasImageUpload,User,PrincipalRef,Branding,PublicBranding,InstanceInfo,File,Thumbnail,FilePayload,FileUpdatedEvent,UserPayload,UserUpdatedEvent,InstanceUpdatedPayload,InstanceUpdatedEvent,EventEnvelope,FieldError,Problem; responsesPayloadTooLarge,UnsupportedMediaType,NotFound.docs/api/asyncapi.yaml: messagesevent.file.updated,event.user.updated,event.instance.updated(x-receive-action).- ADRs 0008, 0010, 0013, 0017, 0033, 0044, 0047, 0048, 0050, 0053.
docs/DECISIONS.md: D-L16, D-FI-06, D-FI-11, D-K-G2, D-W-100; section 10 for the row format.docs/security/threat-model.md: T-UP-01, T-AZ-01.docs/RISKS.md: RK-41.docs/OPEN-QUESTIONS.md: no open question applies.docs/observability/signals.md:bothy.file.upload.size,bothy.file.scan(no new metric here).docs/operations/configuration.md: section 6 (branding.logo_file_id, type uuid, default empty) and the keysfile.max_upload_bytes,file.upload_part_bytes,file.upload_session_ttl,file.upload_max_open_sessions,file.fs.min_free_bytes,file.denied_mime_types,file.image.max_pixels,file.thumbnail.medium_px,file.scan.mode,auth.step_up_max_age.- Briefs this ticket builds on:
docs/briefs/M1-26-identity-principals.md(Directory,PrincipalView.AvatarFileID, the avatar foreign key left to M3-03),M1-28-authz-core.md(kernel/authorize.Resource, requirement table, visibility table, the deferredfile.readentry),M1-31-identity-people.md(UpdateMyProfile, eventuser.updated, the answer for non-humans),M1-46-instance-settings.md(settings registry,branding.logo_file_id"written only by M3-10", branding version as sum, discoverylogo_url),M1-16-event-log.md(eventlog.EventwithAudiencePrincipalID),M1-24-audit-chain.md(auditing.Recorder,DetailsV1),M1-14-background-jobs.md(jobs.Inserter.InsertTx),M1-18-http-chain.md(route limits, SEC-HDR defaults, CORS forinstance.discovery.read),M1-35-identity-step-up.md,M1-12-postgres-migrations.md(pgtest). The M3 briefsM3-01-object-storage-fs.md,M3-03-upload-sessions.md,M3-04-clamd-scan.md,M3-06-image-metadata-thumbnails.mdand, for the neighbouring scope,M3-05-file-download.md(indocs/briefs/once committed); where they and the merged code differ, the merged code wins. - Not in the header because they follow from M3-06: M3-04, M3-03, M3-01, M1-55, M1-46, M1-31, M1-28 and the rest of their closure. M1-63 (
authorize.ResourceFromEvent) and M1-65 (gateway delivery) are in the closure as well; see Design note 7. - Code:
internal/files(M3-01 object store port, M3-03 upload sessions andgetFile, M3-04 jobfiles.scanand theScannerport with its fake, M3-06 jobfiles.thumbnail),internal/identity(M1-26, M1-31),internal/instance(M1-46),internal/authz(M1-28, and thefile.readentry that M3-03 put in place of the deferred one),internal/kernel/authorize,internal/kernel/auditing,internal/kernel/eventlog,internal/platform/httpserver,internal/wire,architecture/modules.yaml,api/operations.yaml.
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, right 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:
- Every file has a purpose that decides its read path (03
files.purpose, 05 2.2, 12 3.7):avataris readable by every active signed-in principal (auth);brandingis public likeinstance.discovery.read, and the only anonymous path to its bytes isgetBrandingLogo;gdpr_exportis never readable throughfile.read. - Stored images are free of metadata, without a switch (12 7.1, ADR 0047, D-L16, SEC-UP-10, R-SE-06, 06 10 point 4): JPEG and PNG originals are filtered losslessly by the allow list, still WebP is converted, every variant is re-encoded in pure Go with the orientation applied and only the unchanged colour profile kept.
- Avatar and logo run through the same verification pass and the same scan as every file (12 3.7, 12 3.4, 12 5, R-FI-05): nothing is delivered before the file is deliverable (12 2), and the scan fails closed.
- Operations and rights (05 4.1 to 4.3,
openapi.yaml):setMyAvataranddeleteMyAvatarwithself.profile.update(self, no step-up, no audit,rl.write);uploadBrandingLogoanddeleteBrandingLogowithinstance.branding.update(owner, step-up, audit,rl.admin);getBrandingLogowithinstance.discovery.read(public,rl.public_auth). - Images arrive as
multipart/form-datawith the partfile(06 2 'Media types',ImageUpload); errors use the closed codes of 06 5:400 validation_failed,413 payload_too_large(detail names the key),415 unsupported_media_type(withreasonfile_type_deniedfor types infile.denied_mime_types, 12 6.1). - Events (05 6, 12 2, ADR 0053):
user.updatedwhen an avatar changes;instance.updatednaming onlybranding.logo_file_idwhen the logo changes;file.updatedfor every state change of an avatar or logo file; no JSONnull. - No parser for an untrusted format runs in the API process (SEC-UP-5): the request path reads bytes, counts them, hashes them and checks the declared type; type detection, filtering and decoding run in the worker jobs of M3-04 and M3-06.
- File responses carry the headers of SEC-HDR-1, SEC-HDR-4 and 12 9.3; the public logo response additionally carries
Access-Control-Allow-Origin: *without credentials (SEC-CORS-2). - No object store call inside a database transaction (02 8.5); the database knows every object key before the object exists (12 4.1, D-FI-11).
- The handler of an operation lives in the module that owns its action (02 4.6, 7.3):
identityforself.profile.update,instanceforinstance.branding.updateandinstance.discovery.read; both reachfilesonly through declared source ports (02 4.3). - No read right gives
404, the same for an unknown id (05 rule 2, ADR 0017, AZ-P4).
Scope¶
All paths under services/server/ unless noted. No new configuration key, no new metric, no new job.
Create:
internal/files/domain/purpose.go: pure rules per purpose:DeclaredImageTypeAllowed(p Purpose, mime string) bool,StoredOriginalAllowed(p Purpose, k OriginalKind) bool(whereOriginalKindis the outcome of M3-06's original step: filtered JPEG, filtered PNG, converted still WebP, re-encoded GIF, stored unchanged; reuse M3-06's type if it has one),FixedOriginalName(p Purpose) string,ImageCap(maxUploadBytes, partBytes int64) int64.internal/files/app/images.go: use casesStageImage,CompleteStagedImage,ReleaseImage,OpenImageVariant(Design notes 2 to 4, 8).internal/files/adapters/profileimages/:avatar.goimplementingidentity.AvatarFiles,logo.goimplementinginstance.LogoFiles; thin delegation tofiles/app, no logic.internal/identity/app/avatar.go:SetMyAvatar,DeleteMyAvatar; queriesinternal/identity/adapters/postgres/queries/avatar.sql(lock the principal row, set and clearavatar_file_id).internal/instance/app/logo.go:UploadLogo,RemoveLogo,OpenLogo; queries for the locked read and write of the setting row ininternal/instance/adapters/postgres/queries/.- Tests listed below, with their fixtures.
Change:
internal/identity(port): declare the source portAvatarFilesand its DTO (Design note 1); addSetMyAvatar(ctx, actor, AvatarImage) (User, error)andDeleteMyAvatar(ctx, actor) errortoDirectorynext toUpdateMyProfile.internal/identity/adapters/httpapi: strict-server methodsSetMyAvatar,DeleteMyAvatar; make sure theUsermapping writesavatar_file_idwhen set and omits it otherwise.internal/instance(port): declare the source portLogoFilesand its DTOs (Design note 1).internal/instance/domain/settings.go: the registry entrybranding.logo_file_idaccepts a UUIDv7 or the empty string (no logo);Brandingand discovery treat the empty string like an absent row (Design note 9).internal/instance/adapters/httpapi: strict-server methodsUploadBrandingLogo,DeleteBrandingLogo,GetBrandingLogo, with a response type that writes the headers of Design note 8.internal/files: the step of M3-04 or M3-06 that decides how the original is stored applies the purpose rule (Design note 4); the one place that appendsfile.updatedwrites the personal form foravatarandbranding(Design note 7); everyfiledescriptor thatfilesbuilds carries the purpose (Design note 6).internal/kernel/authorize:Resource.FilePurpose string(valuesattachment,avatar,branding,gdpr_export) if M3-03 did not add an equivalent field; the event-row descriptor constructor of Design note 7.internal/authz/domain(requirements.go,visibility.go):file.readby purpose (Design note 6);internal/authzREADME.- If the merged
filestable lackspurposeor its two checks from 03 4.7 (the plan row namesfiles.purposehere): a new migrationinternal/files/adapters/postgres/migrations/<next>_files_purpose.sqladding them exactly as 03 4.7, withmake migrations-lock. If they exist, no migration. internal/wire: pass the twoprofileimagesimplementations toidentityandinstance; mount the five handlers; keep the golden files current.architecture/modules.yaml:depends_onoffilesbecomes[identity, chat, instance]; regenerate.go-arch-lint.yml(go generate ./tools/archgen).api/operations.yaml: enablesetMyAvatar,deleteMyAvatar,uploadBrandingLogo,getBrandingLogo,deleteBrandingLogo; rerun the route generator (make codegen-check).internal/platform/httpserver: only if the merged tree has no writer for the SEC-HDR-4 file headers yet (M3-05): add one exported helper there and name it in the report so that M3-05 uses the same writer; never two writers.- Specification (English, in the style of each chapter):
docs/spec/12-files.md3.7: a paragraph on avatar and logo with the rules of Design notes 2 to 4, 7 and 8 (one part, allowed types, fixedoriginal_name, link at upload, release of the previous file, personalfile.updated, the variant served bygetBrandingLogo); 12: a row "Avatar replaced or removed, logo replaced or removed" (deleted mark in the same transaction, objects throughfiles.purge_objects); 13: replace the last paragraph (session endpoints under the profile and branding path) by the multipart operations ofopenapi.yaml.docs/spec/07-permissions.md4.3: a bullet "File withpurpose = 'avatar'or'branding':pis logged in (05, 2.2);gdpr_exportnever throughfile.read".docs/spec/02-architecture.md4.3 (rowinstance: "declares ...LogoFiles"; rowfiles: implementsinstance.LogoFiles), 4.4 (rowfiles:identity,chat,instance, reason "implementsAvatarFilesandLogoFiles; check the upload channel");docs/architecture/c4-3-server-components.mmdthe edgefiles --> instanceand its SVG asdocs/architecture/README.mddescribes.docs/spec/06-api.md8 point 2: Branding means name and accent; replacing and removing the logo need noIf-Match(Design note 10).docs/api/openapi.yaml: descriptions only:ImageUpload(JPEG and PNG filtered losslessly, still WebP converted, thumbnails re-encoded, at most one upload part),setMyAvataranduploadBrandingLogo(checks of Design note 2; the file becomes deliverable later),getBrandingLogo(variantmediumof a deliverable logo,404otherwise). No schema, parameter or response change.
- READMEs of
files(purpose rules, the two source ports, the new dependency oninstance, "May use the ports of these modules:" line),identity,instance,authz;services/server/CHANGELOG.mdunder 'Unreleased';docs/DECISIONS.mdsection 10 rowsD-M3-10-<n>(Design note 11).
Out of scope¶
downloadFile,downloadThumbnail,createFileTicket, the ticket check and the response writer for attachments (M3-05). This ticket proves the avatar read rule throughgetFileandauthorize, and through the download routes only if M3-05 is merged in the base branch (see Tests).- Removing the objects of a released avatar or logo and of an abandoned staged session (
files.purge_objects,files.cleanup_pending, M3-08); this ticket setsfiles.deleted_atand leaves sessions to expire. - Attaching files to messages and the rule that only
attachmentfiles attach (M3-07). - Avatars of agents through
agent.update:openapi.yamlhas no operation for it (spec gap reported); avatars of other people throughupdateUser: not in the schema. - Delivery of events over the gateway and REST catch-up (M1-63, M1-65); this ticket writes the events and the receive rule.
- Display of avatars and logo in the apps and the console (M4-22, M4-25, M5, M6-09).
- Export and erasure of avatar files for a data subject request (M7, P-14).
- The S3 driver (M3-02); tests use the file system driver.
- Real clamd and EICAR (M3-04); this ticket uses the fake
Scannerof M3-04. - 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-10-avatar-logo; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(files): ...,feat(identity): ...,feat(instance): ...) 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 the merged code of M3-01, M3-03, M3-04 and M3-06: follow their names. If the merged M3-06 lets a JPEG or PNG become deliverable before its metadata are removed (12 7.1: "free of metadata as soon as they are stored"), stop with
Verdict: BLOCKED <reason>. - No real third-party service and no real clamd in tests: the object store is the file system driver of M3-01 in a
mktemp -dfolder, the scanner is the fake of M3-04. Image fixtures are synthetic, built in Go in the test or taken from M3-06's test data; no photo of a real place or person, GPS values are made up.
Design notes¶
-
Modules and ports (02 4.3, 4.4, 4.6, 7.3). The handlers sit in the module that owns the action:
identityforsetMyAvataranddeleteMyAvatar,instancefor the three logo operations. They reachfilesonly through source ports that they declare andfilesimplements, wired ininternal/wire.identity.AvatarFilesis already named in 02 4.3 and in both READMEs.instancemay use nothing (02 4.4), so it declaresLogoFiles, andfilesgains the edgefiles -> instanceto implement it. The edge points down (instanceis the lowest layer) and makes no cycle. Ruling for docs/DECISIONS.md. Change 02 4.3, 02 4.4, the diagram,modules.yamland the files README together;make register-check(C7, C11) andmake archkeep them equal. Port shapes (port packages import onlykerneland the standard library; each port has its own DTOs):// package identity type AvatarImage struct { DeclaredType string // Content-Type of the multipart part "file" Body io.Reader // the part; read at most ImageCap plus one byte } type AvatarFiles interface { Stage(ctx context.Context, uploadedBy ids.ID, img AvatarImage) (ids.ID, error) // outside any unit of work Attach(ctx context.Context, fileID, uploadedBy ids.ID) error // inside the caller's unit of work Release(ctx context.Context, fileID, uploadedBy ids.ID) error // inside the caller's unit of work } // package instance type LogoImage struct { DeclaredType string; Body io.Reader } type LogoVariant struct { Body io.ReadCloser ContentType string // detected type of the variant SizeBytes int64 } type LogoFiles interface { Stage(ctx context.Context, uploadedBy ids.ID, img LogoImage) (ids.ID, error) Attach(ctx context.Context, fileID, uploadedBy ids.ID) error Release(ctx context.Context, fileID ids.ID) error OpenVariant(ctx context.Context, fileID ids.ID) (LogoVariant, error) // errs not_found unless deliverable with variant medium } -
Staging an image (
files/app.StageImage, called throughStage, outside any transaction). The caller has already calledauthorizeand gotallow; nothing of the body is read before that (Design note 5). Checks in this order, nothing stored on any failure: a. The first part is namedfile; otherwise400 validation_failedwith pointer/fileand coderequired. b. The declaredContent-Typeof the part isimage/jpeg,image/pngorimage/webp(ImageUpload); otherwise415 unsupported_media_typewithoutreason. A declared type listed infile.denied_mime_typesgives415 unsupported_media_typewithreasonfile_type_denied(12 6.1, first step). c. Read the part into memory up toImageCap(file.max_upload_bytes, file.upload_part_bytes)(the smaller of the two) plus one byte, counting and hashing (SHA-256). More bytes:413 payload_too_large,detailnames the key that set the cap. No byte:400 validation_failed, pointer/file, coderequired. d. A further part afterfile:400 validation_failed(06 2 'Unknown values'; same pointer and code as the chain reports unknown members). e. The limits of 12 3.1 points 4 and 5 apply as for every upload: open sessions of the principal underfile.upload_max_open_sessions(else429withRetry-After), free space for the file system driver at leastfile.fs.min_free_bytes(else503 unavailablewithreasonstorage_full). The quota of 12 3.1 point 3 is not checked: an avatar or logo replaces the previous one, so it adds at most one live file per person or instance, and the operations document no409. Ruling for docs/DECISIONS.md. f. Rows before bytes: run M3-03's session creation without channel and withoutauthorize(the caller authorized its own action, as callers of every port do):filesrow withpurposeavatarorbranding,scope_channel_idNULL,uploaded_by,upload_state = 'pending',scan_status = 'pending',original_name=FixedOriginalName(avatarorlogo),declared_mime_type,size_bytesandsha256= the counted values;file_upload_sessionsrow in modedirectwith the counted size and hash as declared values and one part. Then write the one part through M3-03's part logic (object write, part row); for one part the driver may use a singlePut(12 3.4 point 2). If M3-03's complete use case mixes the object half (for S3CompleteMultipart) with the database half, split it insidefilesso the object half runs here. g. Return the file id. The session staysopen; if anything later fails, it expires afterfile.upload_session_ttlandfiles.cleanup_pending(M3-08) removes it (12 12).Reasons: the server must know size and SHA-256 before the first object write (03
file_upload_sessions.declared_size_bytesanddeclared_sha256are required, 12 4.1 the server knows every key from the database), and one part is the memory bound that every part upload already has (12 3.2). The multipart file name is ignored: an avatar or logo file is readable by every signed-in principal and the name could carry personal data (12 1 point 5). Both are rulings for docs/DECISIONS.md. No type detection, no image header read and no decoding happen here (SEC-UP-5). -
Linking (one unit of work per operation; lock order: anchor row first, then
filesrows, then the counters through the phase hooks, 03 6.3; deadlocks are retried by the unit of work,database.tx_retry_max). The file is linked at upload, before it is deliverable; clients see409 file_not_readywithRetry-Afteruntil it is (12 9.3), the login screen sees404and shows the product mark (01 12). Ruling for docs/DECISIONS.md.SetMyAvatar:authorize(actor, self.profile.update, {kind: user, id: actor, OwnerID: actor}); a principal that is not a human gets the same403 forbiddenwithreasonsubject_kind_not_allowedasupdateMyProfile(M1-31).Stage. Unit of work:SELECT avatar_file_id FROM principals WHERE id = $actor FOR UPDATE;Attach(files: the session isopen, the file isavatarand belongs to the actor, else internal error; sessioncompletedwithcompleted_at; jobfiles.scanthroughInsertTx); setavatar_file_id;Releaseof the previous file if any (files:deleted_at = now()only wherepurpose = 'avatar',uploaded_by= actor anddeleted_at IS NULL); appenduser.updatedwithUserPayloadafter the change. Answer200withUser. No audit (05: Auditno).DeleteMyAvatar: sameauthorize; unit of work: lock the principal row; no avatar: no write, no event,204; otherwise clearavatar_file_id,Release,user.updated,204(06 7: DELETE is idempotent).UploadLogo:authorize(actor, instance.branding.update, {kind: instance})(owner, step-up);Stage. Unit of work: lock the setting rowbranding.logo_file_id(insert it withON CONFLICT DO NOTHINGwhen absent, thenSELECT ... FOR UPDATE);Attach; write the new id withversion + 1,updated_by,updated_at;Releaseof the previous logo file if any (onlypurpose = 'branding'); appendinstance.updatedwithchanged_keys = ["branding.logo_file_id"](M1-46 event shape); audit allow (Design note 9). Answer200withBrandingbuilt by M1-46's code (newversion).RemoveLogo: sameauthorize; unit of work: lock as above; no logo: no write, no event, audit allow with emptychanged_keys,204; otherwise write the empty string withversion + 1,Release,instance.updated, audit,204.
-
Purpose rule in the pipeline (12 6.1 second step, 12 7.1). Where M3-06 decides how the original is stored (filtered JPEG or PNG, converted still WebP, re-encoded GIF, or stored unchanged), a file with purpose
avatarorbrandingis accepted only as filtered JPEG, filtered PNG or converted still WebP (StoredOriginalAllowed). Any other outcome (animated WebP, still WebP that cannot be converted because it exceedsfile.image.max_pixels, GIF, HEIC, AVIF, TIFF, SVG, any non-image) setsupload_state = 'failed'withfailure_reasonfile_type_deniedon the session andfile.updated, before the file can become deliverable, and the object is removed like any failed upload. Reason:ImageUploadadmits PNG, JPEG and WebP, and the promise "without metadata" holds only for originals the server filtered or converted (12 7.1 'Limit of this promise'). A JPEG or PNG overfile.image.max_pixelsis filtered without decoding, stays deliverable and gets no variants (12 7.2). Ruling for docs/DECISIONS.md. Attachments are unchanged. -
Authorize before the body. The generated strict handler receives the multipart reader; the use case calls
authorizebefore it reads the first part. An anonymous caller, a principal without the right, an owner without fresh step-up (401 step_up_required), an owner with a personal token (403 forbidden,reasonstep_up_not_possible, SR5) and a non-owner on the logo (403 forbidden,reasonowner_only) are answered without one byte of the body read. Denials ofinstance.branding.updateare audited by the authz layer (Audityes, R-AZ-04). The chain caps the body atfile.max_upload_bytesand applieshttp.transfer_timeoutto multipart routes (02 9.1 step 5, M1-18). -
file.readby purpose (05 2.2, 07 4.2 and 4.3).filessetsFilePurposeandOwnerID(=uploaded_by) in everyfiledescriptor it builds.authzdecidesfile.read, in addition to M3-03's rule forattachment:avatar: visible and allowed for every principal that passes step 1 of 07 4.2 (active, not anonymous, not pending); apps still need scopefiles:read(05 2.6).branding: the same over file routes. Anonymous callers never reach this branch:file.readis notpublicin the catalogue, soauthorizeanswersunauthenticatedin step 1. The public path isgetBrandingLogowithinstance.discovery.read.gdpr_export: never visible throughfile.read(not_found); its only path isgdpr.export.download(M7).- A deleted file (
deleted_atset) isnot_foundfor everyone, with the body and headers of an unknown id (AZ-P4). - A
filedescriptor without path hint and without purpose (built from an event row, note 7) is visible only whenOwnerIDis the principal; anything else fails closed. Write these rules intorequirements.goandvisibility.gowith the 05 text asSource, and the visibility bullet into 07 4.3.
-
Events.
user.updated: resource{kind: user, id}without version, empty scope,UserPayload{user}withavatar_file_idpresent when set (M1-31 shape), receive actionuser.read.instance.updated: resource{kind: instance, id: instance_id}, empty scope,{changed_keys: ["branding.logo_file_id"]}(M1-46 shape), receive actioninstance.read.file.updatedof an avatar or logo file (written by the jobs of M3-04 and M3-06 at every state change): a personal event of the uploader,audience_principal_id = uploaded_by, empty scope, resource{kind: file, id}, receive actionfile.read, payloadFilePayload. Reason: only the uploader waits for the result; every other reader learns of the new file throughuser.updatedorinstance.updatedand retries a409 file_not_readyafterRetry-After; and the event row has no column for the purpose (03event_log), so a broadcast could not be checked per recipient. Ruling for docs/DECISIONS.md. For the receive check the descriptor built from an event row of kindfilesetsOwnerIDfromaudience_principal_id; then note 6 admits exactly the uploader.authorize.ResourceFromEvent(M1-63) is merged: extend it this way (for kindfileonly) and adjust its call sites.- All payloads validate against their schemas; no JSON
null.
-
getBrandingLogo.authorize(principal or anonymous, instance.discovery.read, {kind: instance}); readbranding.logo_file_id; empty or absent:404.LogoFiles.OpenVariantreturns the variantmedium(longest edgefile.thumbnail.medium_px, re-encoded, 12 7.3) of a deliverable file with purposebranding; a file that is pending, blocked, failed, deleted or without that variant gives404. Every404here has the same status, body and headers apart fromCorrelation-Id. Reasons: the public endpoint serves only pixels the server encoded itself, never bytes whose structure came from a client; one variant keeps the response small. Ruling for docs/DECISIONS.md. Answer200with:Content-Type: the variant's type (image/jpegorimage/png);Content-Disposition: inline; filename="logo.<ext>"; filename*=UTF-8''logo.<ext>(12 9.3 inline list: thumbnails);Content-Security-Policy: default-src 'none'; sandbox,X-Content-Type-Options: nosniff,Cross-Origin-Resource-Policy: same-origin,Access-Control-Allow-Origin: *from the chain (SEC-CORS-2);Cache-Control: no-store(the chain default stays): the URL is stable while its content changes, 06 8 point 6 has no304, and 12 9.3 defines caching only with sign-in or ticket. Ruling for docs/DECISIONS.md;ETag: "<sha256 hex of the variant>"andRepr-Digest: sha-256=:<base64>:;file_thumbnailshas no hash column, so read the variant once, hash it, then send headers and body (its size is bounded byfile.thumbnail.medium_px);- no
Rangesupport (openapi.yamllists no206):Rangeis ignored,200with the whole variant.
-
Logo setting and audit. The setting row
branding.logo_file_idis never deleted: removing the logo stores the empty string, which configuration.md section 6 calls the default ("empty"), so the branding version (sum of the three setting versions, M1-46) rises on every change and an oldIf-Matchnever matches again.Branding.logo_file_idand discoverylogo_urlare omitted for the empty string.putInstanceSettingkeeps rejecting branding keys (M1-46). Audit allow entries ofinstance.branding.updatethroughauditing.Recorderin the unit of work: upload{changed_keys: ["branding.logo_file_id"], file_id}, removal{changed_keys: ["branding.logo_file_id"]}or{changed_keys: []}for a removal without logo; only ids and keys, no names (05 2.4: every allow of an action with Audityesis recorded). Rulings for docs/DECISIONS.md. -
No
If-Matchon the logo.openapi.yamlgivesuploadBrandingLogoanddeleteBrandingLogoneitherIfMatchRequirednor412or428, while 06 8 point 2 lists Branding among the objects that needIf-Match. Followopenapi.yaml: the logo is replaced or removed as a whole, last write wins, and the row lock of note 3 serializes concurrent writes. Clarify 06 8 point 2 accordingly. Ruling for docs/DECISIONS.md. -
Rulings for
docs/DECISIONS.md(section 10, one row each,D-M3-10-<n>): the edgefiles -> instancewithinstance.LogoFiles(note 1); staging in a server-opened session of one part, capped by the smaller offile.upload_part_bytesandfile.max_upload_bytes(note 2); fixedoriginal_nameavatarandlogo(note 2); no quota check for avatar and logo (note 2); link at upload, release of the previous file withdeleted_at(note 3); purpose rule withfailure_reasonfile_type_denied(note 4);file.readby purpose includinggdpr_exportnever throughfile.read(note 6); personalfile.updatedfor avatar and logo files andOwnerIDfrom the audience for event rows of kindfile(note 7);getBrandingLogoserves variantmediumwithCache-Control: no-storeand noRange(note 8); empty string instead of a deleted row, audit details and audit of a removal without logo (note 9); noIf-Matchon the logo operations (note 10). -
Gaps this ticket does not close (name them in the report under spec gaps): agent avatars have no operation in
openapi.yamlalthough 05agent.updateand 12 3.7 name them; the objects of released files and abandoned sessions stay until M3-08 runsfiles.purge_objectsandfiles.cleanup_pending.
Tests (write them first)¶
Unit:
internal/files/domain:TestDeclaredImageTypeAllowed:image/jpeg,image/png,image/webpaccepted foravatarandbranding;image/gif,image/svg+xml,image/heic,text/html,application/octet-streamand the empty string rejected.TestStoredOriginalAllowed: filtered JPEG, filtered PNG and converted still WebP accepted; animated WebP, unconvertible still WebP, GIF, HEIC and unchanged storage rejected for both purposes;attachmentnever rejected by this rule.TestImageCap(both orders of the two keys).TestFixedOriginalName.internal/authz:TestFileReadByPurpose, a table over principals (Alex and Sam as active humans, an agent, an app with and withoutfiles:read, anonymous, a person on a pending device, a disabled person) and purposesavatar,branding,gdpr_export: humans and agentsallowonavatarandbranding; app with scopeallow, withoutforbiddenscope_missing; anonymousunauthenticated; pending deviceforbiddendevice_pending; disabledunauthenticated;gdpr_exportnot_foundfor everyone including the owner and the uploader.TestFileReadEventDescriptor: a descriptor of kindfilewithout path and purpose withOwnerIDAlex givesallowfor Alex andnot_foundfor Sam.internal/instance/domain:TestLogoSettingEmptyMeansAbsent(Brandingwithoutlogo_file_id, discovery withoutlogo_url),TestLogoSettingRejectsOtherValues.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain, real step-up from the identity test helpers, the file system object store in a mktemp -d folder, the fake Scanner of M3-04 answering clean unless a test sets another verdict, jobs files.scan and files.thumbnail run to completion in the test as M3-04 and M3-06 do; Alex and Sam are people, Alex is the owner):
TestSetMyAvatar_HappyPath: Alex uploads the JPEG fixture:200withUser.avatar_file_id; thefilesrow haspurposeavatar,scope_channel_idNULL,original_nameavatar,uploaded_byAlex; the session iscompleted; onefiles.scanjob exists; oneuser.updatedwith the new id; after the jobs the file is deliverable and has the variants of M3-06.TestSetMyAvatar_ReplacesPrevious: a second upload setsdeleted_aton the first file;getFileon the first id answers404with status, body and headers equal to an unknown id (AZ-P4).TestSetMyAvatar_Rejections: declaredimage/gifandimage/svg+xmlgive415;file.denied_mime_typesset toimage/pngand a PNG give415withreasonfile_type_denied; a part one byte over the cap gives413whosedetailnames the key; no partfile, an empty part and a second part give400 validation_failed; after each case there is nofilesrow, no session and no object.TestSetMyAvatar_AuthorizeBeforeBody: with a request body whose reader fails the test when read, anonymous gets401and an agent gets403withreasonsubject_kind_not_allowed; nothing is stored.TestSetMyAvatar_DeclaredTypeLies: declaredimage/png, bytes are HTML: afterfiles.scanthe file isupload_state = 'failed'withfailure_reasonfile_type_deniedand never deliverable;getFileshowsfailed.TestSetMyAvatar_AnimatedWebPFailsandTestSetMyAvatar_GIFAsPNGFails: same outcome through the purpose rule of note 4.TestDeleteMyAvatar:204,avatar_file_idcleared, previous filedeleted_atset, oneuser.updated; a second call204without event; no audit rows for avatar operations.TestAvatarReaders: Sam reads Alex's avatar withgetFile(200,purposeavatar); an agent200; an app withoutfiles:read403scope_missing; anonymous401. IfdownloadThumbnailanddownloadFileare in the enabled list ofapi/operations.yamlin the base branch (M3-05 merged), run the same matrix against them and check the delivered bytes for metadata as inTestImagesWithoutMetadata; otherwise list these cases under "What is not proven" in the report.TestAvatarConcurrentReplace: two parallel uploads of Alex both end200or with a defined status (no deadlock reaches the client); afterwards exactly one file is linked and every other avatar file of Alex hasdeleted_at.TestUploadBrandingLogo_HappyPath: the owner with fresh step-up uploads the PNG fixture:200withBranding.logo_file_idandversionraised by one; oneinstance.updatedwith exactly["branding.logo_file_id"]; one audit allow entry withchanged_keysandfile_id; after the jobs anonymousgetBrandingLogoanswers200with the bytes of themediumvariant and the headers of note 8 (SEC-T03 for this response kind:Content-Type, bothfilenameparameters, CSP,nosniff, CORP,Cache-Control: no-store,ETag,Repr-Digest,Access-Control-Allow-Origin: *, noAccess-Control-Allow-Credentials).TestUploadBrandingLogo_Rights: Sam gets403withreasonowner_onlyand one deny entry in the audit log (AZ-P13 shape); the owner with step-up older thanauth.step_up_max_agegets401 step_up_required(AZ-M36 shape); the owner with a personal API token gets403withreasonstep_up_not_possible(AZ-M35 shape); anonymous gets401; the body is never read in these four cases.TestGetBrandingLogo_NotDeliverable: no logo, logo pending (scanner not ready), logoinfected(fake verdict) and a JPEG logo overfile.image.max_pixels(deliverable, no variant) each give404with equal status, body and headers apart fromCorrelation-Id; before setup the answer is404, not503 setup_required.TestLogoPublicOnlyThroughGetBrandingLogo: anonymousgetFileon the logo id gives401; anonymousdownloadFileanddownloadThumbnailgive401when enabled; Sam signed in reads the metadata withgetFile(200,purposebranding).TestDeleteBrandingLogo:204; the setting holds the empty string with a raised version; discovery has nologo_url;getInstancehas nologo_file_id;getBrandingLogo404; the old file hasdeleted_at; oneinstance.updated; one audit allow entry; a second call204, no event, one audit allow entry with emptychanged_keys.TestLogoVersionMonotonic: upload, remove, upload: the branding version rises strictly;updateBrandingwith theIf-Matchof the firstBrandinggets412 version_conflict;updateBrandingkeepslogo_file_id.TestImagesWithoutMetadata(plan: "images re-encoded without metadata"), for avatar and for logo, with fixtures: JPEG with GPS EXIF, XMP, IPTC,COM,APP2with Multi-Picture-Format,APP2withICC_PROFILE, orientation 6 and bytes after the firstEOI; PNG withtEXt,iTXt,zTXt,eXIf,tIMEand an unknown ancillary chunk; still WebP withEXIFandXMPchunks. Assert: the stored original of the JPEG has exactly oneAPP1with only "Orientation",APP2onlyICC_PROFILE, no otherAPPn, noCOM, nothing after the firstEOI; the PNG has only allow-listed chunks; the WebP is stored as PNG or JPEG without any metadata; every variant decodes, is not larger than its key's edge, has the orientation applied (width and height swapped for orientation 6), carries no EXIF, XMP,COMor PNG text oreXIfchunk, and keeps the colour profile of the original where there is one; the bytes ofgetBrandingLogosatisfy the same.TestAvatarAndLogoFileEvents: everyfile.updatedof an avatar or logo file hasaudience_principal_id= uploader, empty scope, resource kindfile, receive actionfile.read, and validates againstFileUpdatedEvent;user.updatedandinstance.updatedvalidate against their schemas; no JSONnullanywhere.TestImageUploadSEC_T08(SEC-T08 for these routes): false type declaration (failed, never delivered), SVG with script (415), image overfile.image.max_pixels(no decoding, no variant), size over the cap (413), scanner not ready (stayspending, logo404), infected verdict of the fake scanner (never delivered, logo404).TestRoutesRegistered(AZ-P1): the five routes are registered with their actions and rate-limit classes.
Fuzz:
FuzzImageUploadPartsininternal/identity/adapters/httpapi(or where the multipart handling sits): random multipart bodies (part count, names, headers, sizes around the cap) never give a5xx, and every non-2xxanswer leaves nofilesrow. Run it once withgo test -run=^$ -fuzz=FuzzImageUploadParts -fuzztime=<duration of your choice>and report duration and result; commit any crashing input as a seed after the fix.
Every response in these tests passes the response validation of the test build (M1-17, M1-18).
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/files/... ./internal/identity/... ./internal/instance/... ./internal/authz/... ./internal/kernel/... ./internal/platform/httpserver/... ./internal/wire/...make codegen-check,make arch,make register-check,make api-lint,make api-checksmake diagrams,make docs,make linkcheckmake check
Acceptance criteria¶
-
setMyAvatar,deleteMyAvatar,uploadBrandingLogo,getBrandingLogoanddeleteBrandingLogoare enabled and registered with their actions; AZ-P1 passes. - Images re-encoded without metadata: the stored originals of avatar and logo carry no metadata (JPEG and PNG filtered losslessly, still WebP converted), every variant is re-encoded without metadata with orientation applied, and an image that cannot be stored without metadata ends
failedand is never delivered. - The logo is publicly readable only through
getBrandingLogo: anonymous callers get themediumvariant with the headers of SEC-HDR-4, 12 9.3 and SEC-CORS-2 there,401on every file route, and the same404while no deliverable logo exists. - Avatars only for entitled readers: every active signed-in principal reads an avatar file through
file.read; anonymous401, an app withoutfiles:read403;gdpr_exportnever throughfile.read; a replaced or removed avatar gives the same404as an unknown id. - Logo changes need an owner with fresh step-up, are audited on allow and deny, write
instance.updatedwith onlybranding.logo_file_id, and raise the branding version; avatar changes writeuser.updated;file.updatedof these files reaches only the uploader. -
authorizeruns before the body is read; an upload rejected while it is staged stores nothing (Design note 2); a staged file that fails later stays as Design notes 2 g and 4 andTestSetMyAvatar_DeclaredTypeLiesdescribe. - The specification changes of Scope (02, 06, 07, 12,
openapi.yamldescriptions, diagram, module register) and theD-M3-10-<n>rows are in the same change. -
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,identity,instanceandauthzupdated (the source portsAvatarFilesandLogoFiles, the purpose rules, the read rules by purpose, the personalfile.updated, the new dependencyfiles -> instance). - Rulings of note 11 recorded in
docs/DECISIONS.mdsection 10 (one row each with reason and place). 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>.