Skip to content

M3-10 Avatar and logo

No Date Ruling Reason
D-M3-10-1 2026-10-20 instance declares the source port instance.LogoFiles (with LogoImage, LogoOpener, LogoVariant) and files implements it next to identity.AvatarFiles in adapters/profileimages, wired in internal/wire; files gains the dependency files -> instance. The handlers stay in the modules that own the actions: identity for setMyAvatar and deleteMyAvatar, instance for the three logo operations. Place: internal/instance/logo.go, architecture/modules.yaml, 02 4.3 and 4.4, docs/architecture/c4-3-server-components.mmd. 02 4.4 lets instance use nothing, so it cannot call files; the new edge points down to the lowest layer and makes no cycle (02 4.6 and 7.3: the handler lives in the module that owns the action).
D-M3-10-2 2026-10-20 An avatar or logo is staged by the server in a session it opens itself, mode direct, with exactly one part, outside any transaction and only after authorize allowed the action: first part file, declared type image/jpeg, image/png or image/webp (a type in file.denied_mime_types gives 415 with reason file_type_denied), the part read into memory up to ImageCap (the smaller of file.max_upload_bytes and file.upload_part_bytes) plus one byte while it is counted and hashed, 413 whose detail names the key that set the cap, no further part; then the files row, the session with the counted size and SHA-256 as declared values, and the one part. A failure stores nothing; a staged file whose unit of work fails stays an open session until file.upload_session_ttl. Place: files/domain/purpose.go, files/app/profileimages.go (StageImage), platform/httpserver.ImagePart, 12 3.7. The database must know size and SHA-256 before the first object write (03 file_upload_sessions, 12 4.1, D-FI-11); one part is the memory bound every part upload already has (12 3.2); no type detection or decoding runs in the API process (SEC-UP-5).
D-M3-10-3 2026-10-20 original_name of an avatar file is avatar and of a logo file logo (FixedOriginalName); the file name of the multipart part is ignored. Place: files/domain/purpose.go, 12 3.7. Every signed-in principal reads these files, and a client's file name could carry personal data (12 1 point 5).
D-M3-10-4 2026-10-20 Staging an avatar or logo checks the open sessions (file.upload_max_open_sessions, 429) and the free space of the file system driver (file.fs.min_free_bytes, 503 unavailable with reason storage_full) as 12 3.1, but not the quota of 12 3.1 point 3. Place: files/app/profileimages.go, 12 3.7. An avatar or logo replaces the previous one, so it adds at most one live file per person or instance, and openapi.yaml documents no 409 for these operations.
D-M3-10-5 2026-10-20 The new file is linked in the unit of work of the upload, before it is deliverable: lock the anchor row (the principal row, or the setting row branding.logo_file_id under the settings lock of M1-46, inserted when absent), complete the session and insert files.scan (CompleteStagedImage), write the new id, set deleted_at on the previous file of the same purpose and uploader (ReleaseImage), append user.updated or instance.updated. Readers get 409 file_not_ready with Retry-After until the file is deliverable, getBrandingLogo 404. The objects of a released file and of an abandoned session stay until files.purge_objects and files.cleanup_pending of M3-08 remove them. Place: identity/app, instance/app, files/app/profileimages.go, 12 3.7 and 12. One unit of work keeps link and release consistent and the lock order of 03 6.3 (anchor row, then files); a client sees the change at once and retries the bytes, the login screen shows the product mark meanwhile (01 12).
D-M3-10-6 2026-10-20 Purpose rule: a file with purpose avatar or branding is kept only when its original is stored as filtered JPEG, filtered PNG or converted still WebP (StoredOriginalAllowed over OriginalKind). The scan pass fails a detected type whose original would be stored unchanged or as a re-encoded GIF, and files.thumbnail fails a WebP that stays unchanged (animated, or above file.image.max_pixels); both set upload_state = 'failed' with failure_reason file_type_denied and file.updated before the file can become deliverable, and the objects go like those of every failed upload. A JPEG or PNG above file.image.max_pixels is filtered without decoding, stays deliverable and gets no thumbnails. Attachments are unchanged. Place: files/domain/purpose.go, files/app/scan.go, files/app/images.go, 12 3.7. ImageUpload admits JPEG, PNG and WebP, and the promise "without metadata" holds only for originals the server filtered or converted (12 7.1, the limit of the promise).
D-M3-10-7 2026-10-20 file.read chooses its requirement by purpose: attachment keeps the read right on the path (M3-03); avatar and branding need only auth, so every active, signed-in principal that passes step 1 of 07 4.2 reads them (apps still need the scope files:read), and anonymous callers are answered unauthenticated in step 1 because file.read is not public; gdpr_export is never visible through file.read (not_found, its path is gdpr.export.download). A deleted file is not_found like an unknown id. A file descriptor without path and purpose (built from an event row) is visible only to its OwnerID. Place: authz/domain/requirements.go, visibility.go, 07 4.3. 05 2.2 gives each purpose its own read path; the only anonymous path to logo bytes is getBrandingLogo with instance.discovery.read; a descriptor that cannot be checked fails closed.
D-M3-10-8 2026-10-20 file.updated of an avatar or logo file is a personal event of the uploader: audience_principal_id = uploaded_by, empty scope, resource {kind: file, id}, receive action file.read, payload FilePayload. authorize.ResourceFromEvent takes the audience and sets OwnerID from it for kind file only, so the receive check admits exactly the uploader. Place: files/app (FileUpdated), kernel/authorize, 12 3.7. Only the uploader waits for the result; other readers learn of the new file through user.updated or instance.updated and retry after Retry-After; event_log has no column for the purpose (03 4.10), so a broadcast could not be checked per recipient.
D-M3-10-9 2026-10-20 getBrandingLogo serves only the variant medium of a deliverable file with purpose branding (OpenImageVariant), read once to compute ETag ("<sha256 hex>") and Repr-Digest before headers and body are sent, with Content-Disposition: inline and the file name logo.<ext>, the SEC-HDR-4 headers, Access-Control-Allow-Origin: * without credentials, Cache-Control: no-store, and no Range support (Range is ignored, 200 with the whole variant). No logo, a pending, blocked, failed or deleted file and a file without that variant give the same 404. Place: instance/app (OpenLogo), instance/adapters/httpapi, platform/httpserver.SetFileHeaders, 12 3.7. The public endpoint serves only pixels the server encoded itself, never bytes whose structure came from a client; one variant keeps the answer small; the URL is stable while its content changes, 06 8 point 6 has no 304, and 12 9.3 defines caching only with sign-in or ticket; openapi.yaml lists no 206; file_thumbnails has no hash column.
D-M3-10-10 2026-10-20 The setting row branding.logo_file_id is never deleted: removing the logo stores the empty string (the default of configuration.md section 6) with version + 1, so the branding version rises on every change; Branding.logo_file_id and discovery logo_url are omitted for the empty string, and putInstanceSetting keeps rejecting branding keys. Every allow of instance.branding.update is audited in the unit of work: upload {changed_keys: ["branding.logo_file_id"], file_id}, removal {changed_keys: ["branding.logo_file_id"]}, a removal without logo {changed_keys: []} without a write or an event. Avatar operations write no audit entry. Place: instance/domain/settings.go, branding.go, discovery.go, instance/app (UploadLogo, RemoveLogo). A deleted row would lower the version sum (M1-46) and let an old If-Match match again; 05 2.4 records every allow of an action with Audit yes, self.profile.update has Audit no; only ids and keys, no names.
D-M3-10-11 2026-10-20 uploadBrandingLogo and deleteBrandingLogo take no If-Match: the logo is replaced or removed as a whole, last write wins, and the row lock of D-M3-10-5 serializes concurrent writes. 06 8 point 2 now says that Branding there means name and accent. Place: 06 8. openapi.yaml gives these operations neither IfMatchRequired nor 412 or 428; the API document is binding (12 13).
D-M3-10-12 2026-10-20 Names that differ from the brief: the use cases StageImage, CompleteStagedImage, ReleaseImage and OpenImageVariant live in files/app/profileimages.go, not app/images.go. Place: internal/files/app. M3-06 already uses app/images.go for ImageProcessor.
D-M3-10-13 2026-10-20 Names that differ from the brief: the avatar and logo operations are the incoming ports identity.Avatars (SetMyAvatar, DeleteMyAvatar) and instance.Logos (UploadLogo, RemoveLogo, OpenLogo), not methods of identity.Directory. Place: internal/identity/avatar.go, internal/instance/logo.go. In the merged code identity.Directory is the read-only port for other modules and UpdateMyProfile sits on identity.People, the branding writes on instance.Admin; a port of its own for the image operations keeps those ports unchanged.
D-M3-10-14 2026-10-20 Names that differ from the brief: the use cases take an opener (identity.AvatarOpener, instance.LogoOpener) instead of an AvatarImage or LogoImage value, and call it only after authorize allowed the action. Place: internal/identity/avatar.go, internal/instance/logo.go. The strict handler receives the multipart reader before any check; the opener makes "nothing of the body is read before authorize" a property of the port, not of each handler.
D-M3-10-15 2026-10-20 Added to platform/httpserver: ImagePart reads the multipart part file lazily and reports a further part as 400 validation_failed (the code the chain uses for unknown members); SetFileHeaders is the one writer of the SEC-HDR-4 file headers (Content-Type, Content-Disposition with both filename parameters, CSP, nosniff; Cache-Control and CORP stay with the chain), for getBrandingLogo and for the download routes of M3-05. Place: internal/platform/httpserver/imagepart.go, fileheaders.go. The merged tree had no writer for these headers; two writers could drift apart (SEC-HDR-4, SEC-T03).
D-M3-10-16 2026-10-20 Names that differ from the brief: authorize.ResourceFromEvent (M1-63) takes the audience principal of the event row and sets OwnerID from it for kind file; its call sites pass the audience. Place: internal/kernel/authorize. D-M3-10-8 needs the owner of a personal file event for the receive check, and the event row carries it only as audience_principal_id.
D-M3-10-17 2026-10-20 Build details: the avatar unit locks the person with the existing LockUser query (the same row, and it returns the user record user.updated needs) instead of a new lock query; the logo unit writes the setting row before Attach, so the retry after a concurrent first insert never links the staged file twice; avatar and branding files are visible only for file.read, while file.delete and file.transcribe stay not_found on them (the avatar and logo operations replace and remove them). Place: identity/app/avatar.go, instance/app/logo.go, authz/domain/visibility.go. One lock query per row; the order inside one unit of work is not visible outside it; a path the brief does not open stays closed.
D-M3-10-18 2026-10-20 Build: base int/m1-wave48 at bba7d5a with M3-06 at 820c76a merged first (stacked build), branch feat/M3-10-avatar-logo in its own worktree. The integration tests sit in test/images next to the fixtures of M3-06 and use the harness owner as the owner (the matrix world has Alex as a person, not as owner); the scanner verdicts come from the scripted clamd clamdtest.Fake. The fuzz target FuzzImageUploadParts runs on platform/httpserver.ImagePart, where the multipart handling sits; that a refused image leaves no row is held by the unit tests of StageImage. The download routes of M3-05 are not enabled on this base, so the avatar read matrix runs on getFile only. The task names the base and the dependency; the brief allows the names of the merged code.
D-M3-10-19 2026-10-20 uploadBrandingLogo by an owner with a personal API token answers 403 forbidden with reason owner_only, not step_up_not_possible as the brief's test list says: the personal token of an owner carries no owner rights, so the requirement owner fails before the step-up rule of 07 4.2. The body is not read either way. Place: test/images/avatarlogo_test.go. 07 SR5 ("A personal API token of an owner acts ... without owner permissions") and the merged authz (M1-44) decide; the brief's AZ-M35 shape is that of a non-owner action.
D-M3-10-20 2026-10-20 file.read by purpose exists once, in the form of M3-10: the requirement table chooses by purpose (domain.ByFilePurpose: attachment read, avatar and branding Auth, event rows by path or Self) and fileVisible admits avatar and branding to file.read only. The substitution fileReadRequirement of M3-05 inside Decide is removed, so a branding file needs Auth instead of Public for file.read; this supersedes that part of D-M3-05-18. The resolve case uses fileOnPath of M3-10, and the comments of resolve.go, visibility.go, resource.go, the authz README (with the session rule of M3-05 kept) and the server changelog follow. Both tickets added a rule for the same decision; kept side by side, Public of M3-05 would have overridden the table and broken TestFileReadByPurpose of M3-10 (anonymous unauthenticated). No operation changes: downloadFile is not public, so an anonymous caller gets 401 before authorize, and getBrandingLogo checks instance.discovery.read. The tests of M3-05 (TestFileReadRequirement, test/download) and of M3-10 (TestFileReadByPurpose, test/images) pass together.
D-M3-10-21 2026-10-20 Name clashes of M3-10 with M3-05 in the same packages: files/app.storageError of M3-10 (keeps a kernel/errs error, else 503) is renamed imageStorageError, the one of M3-05 for downloads stays; the second headerCSP constant in platform/httpserver/fileheaders.go is dropped (same value as in csp.go); domain.Deliverable in profileimages.go becomes domain.IsDeliverable (INT-W50/3). The server did not build; each fix keeps the behaviour of both tickets.
D-M3-10-22 2026-10-20 The Dart client (packages/api_client), the Python SDK and the TypeScript types are regenerated from the merged docs/api/openapi.yaml. The descriptions and request bodies of setMyAvatar, uploadBrandingLogo and getBrandingLogo of M3-10 were not in the generated clients, so make api-clients and test_api_client_generate.py failed.
D-M3-10-23 2026-10-22 Port onto main: the squash of the integration branch (with D-M3-10-20 and D-M3-10-21) is applied to main after M3-05 and M3-06; the spec additions go into the English chapters (02, 06, 07, 12, the server component diagram), the changelog entry and these rulings into fragments, and the generated REST layer, Go client and the client SDKs are regenerated from the merged contract. The webhook delivery of apps keeps its own resource descriptor and gets no audience owner. The integration branches are retired (PL/24 fragments, rules v3); the German chapter files and the shared CHANGELOG.md and docs/DECISIONS.md sections are no longer written by tickets.