Spec: F104 — Pulse Attachments (multiple photos & files, by default)
Status: draft
One line. A pulse accepts multiple camera photos or files by default: the evidence that is the report. F103 renders thumbnails so leaders see proof, not only words.
1. Problem & CEO Job
Job: trust the signal. Daily pulse (F07/F83)
captures only answers_json and raw_text, although F34’s presign → S3 → complete → EvidenceSource
rail already serves chat, tasks, and comments. Evidence therefore stays in chat apps while the leadership
surface carries an ungrounded claim.
F104 makes F86 faithfulness literal: AI-extracted risks can cite their grounding photo and leaders see it in the rollup. It supports multiple files by default and mobile-first capture because field work produces more than one artifact.
2. User Stories & Acceptance Criteria
-
As a member submitting my pulse, I want to attach several photos or files at once, so that my report carries its proof without extra steps.
- Given I am filling my pulse, when I tap “Add photos”, then I can select multiple images/files in one pick (or take a photo directly on mobile) and see each as a thumbnail with a remove control.
- Given I attached 3 photos, when I submit, then all 3 are linked to my pulse and visible on my submitted pulse and to my leaders in the rollup.
- Given a file exceeds 10 MB or is a disallowed type, when I pick it, then I get an honest inline error for that file and the others still attach (reuses F34’s 10 MB + MIME allowlist).
- Given I attached more than the per-pulse cap (10), when I add another, then I’m told the cap honestly and the add is blocked — no silent drop.
- Given an upload is still in flight, when I try to submit, then submit waits for in-flight uploads (or lets me submit and shows the attached ones) — I never lose an answer to a slow upload.
-
As a member on a phone, I want attaching to feel native, so that it’s faster than opening WhatsApp.
- Given I’m on mobile web, when I tap “Add photos”, then the OS camera/photo-library sheet opens
(
accept="image/*" captureaffordance) and multi-select is on by default. - Given a slow connection, when uploads run, then I see per-file progress and can remove a stuck one.
- Given I’m on mobile web, when I tap “Add photos”, then the OS camera/photo-library sheet opens
(
-
As a member submitting via the assistant (F91) or Telegram (F21), I want attachments to work in the conversation too, so that the form isn’t the only way to attach (form parity).
- Given I paste/drop images into the assistant pulse flow, when the pulse is created (
source=chat), then those images are linked as attachments the same way the form links them. - Given I send photos to the Telegram pulse bot, when the pulse is stored (
source=telegram), then the photos are ingested through the same upload rail and linked. (Wiring depth per F21’s bot; the data contract is identical.)
- Given I paste/drop images into the assistant pulse flow, when the pulse is created (
-
As a leader reading a report, I want to see and open the attachments, so that I trust the update.
- Given a member’s pulse has attachments, when I open their report (F103),
then I see a thumbnail grid; tapping opens the full image / downloads the file (CDN
public_url). - Given an attachment is a previewable image on the submitted Pulse or report surface, then the inline card shows the thumbnail without repeating the filename; the filename remains available as the image’s accessibility description and full-preview title. Non-image files and unavailable image previews retain their filename and metadata.
- Given a member’s pulse has attachments, when I open their report (F103),
then I see a thumbnail grid; tapping opens the full image / downloads the file (CDN
-
As the org, I want attachments to obey tenant isolation and evidence rules, so that nothing leaks.
- Given org A, when a member references an
upload_idfrom org B or another member’s private upload, then submit rejects it (400/403) — attachments are validated against the caller’s org + ownership.
- Given org A, when a member references an
Every criterion maps to a test in §7.
3. Scope Boundaries
- In:
- A many-to-many link from
PulseEntrytouploads.Upload(the F34 upload) so a pulse carries N files. - Multi-file / photo attach by default on the pulse form (
PulseForm), reusinguseUpload+ a new multi-file variant ofFileAttachButton. submit_pulseacceptsattachment_idsand.set()s them after validating org + ownership + completed.- Rendering attachments on the submitted-pulse view and exposing them in the pulse serializers (so F103 and the native shells can render thumbnails).
- A per-pulse count cap; reuse of F34’s 10 MB/file limit and MIME allowlist.
- Mobile-first capture (camera/multi-select) on web; the same data contract for chat (F91) and Telegram (F21).
- A many-to-many link from
- Out:
- The upload mechanism itself — that is shipped F34 (presign/S3/complete/CDN). F104 consumes it; it does not re-invent uploads.
- The leader read/rollup surface — that is F103. F104 only captures and stores the attachments F103 renders.
- Image processing / resizing / thumbnails server-side — use CDN image optimization (F34 §3 “Out”).
Client renders
public_urldirectly; a?w=CDN param is a fast-follow, not a new pipeline. - Virus scanning, private pre-signed GET — inherited F34 “Out”; all v1 files are public-read via CDN.
- Attaching to extracted items (a photo on a specific Risk/Commitment) — the pulse-level link is v1; per-item evidence photos are a future extension once the pulse carries them.
4. Data Model
One new M2M field, no new table (Django creates the through table; the file rows are the existing
Upload). The upload already creates its own EvidenceSource on complete/ (F34), so provenance and the
evidence-on-every-AI-output rail are satisfied by construction — F104 just attaches those uploads to the pulse.
# backend/apps/pulses/models.py — on PulseEntry
attachments = models.ManyToManyField(
"uploads.Upload",
blank=True,
related_name="pulses",
help_text="Photos/files attached to this pulse (F104). Each Upload also owns its F34 EvidenceSource.",
)
- Why link to
Upload, not a new join model: rendering needspublic_url,content_type,filename— all onUpload(F34 §4). A leader’s rollup card (F103 §5) reads exactly these. TheEvidenceSource(source_type=uploaded_file) already exists per upload for the faithfulness graph; no duplication. - Ownership/tenant invariants enforced at submit (not just DB): each
upload_idmust satisfyorganization_id == request.organization_id,status == "completed", and be owned by the submitting member (upload.user == request.membership) — a member cannot staple someone else’s file to their pulse. - Cap:
PULSE_ATTACHMENT_MAX = 10per pulse (constant inapps/pulses), validated server-side; the UI mirrors it. Chosen small so a rollup card and the CEO Brief stay readable; raise later only if load shows need. - Migration: additive M2M (through table) — no data backfill, no change to
unique_together. Append a new migration viamakemigrations(never edit an applied one).related_name="pulses"onUploadlets F103 prefetchattachmentsin the DISTINCT-ON query (no N+1; see F103 §4 scale-check).
Scale-check (per ENGINEERING.md → Scale-Ready Design): the through table is bounded by
(pulses × ≤10). F103’s rollup uses .prefetch_related("attachments") — one extra query, not per-card. No
FK into a future-huge table; no unbounded count. Nothing to shard.
5. API Contract
Extends the existing pulse endpoints — no new routes.
| Method | Path | Auth/Role | Change |
|---|---|---|---|
| POST | /api/pulses/submit/ | IsOrgMember (not viewer) | Request gains optional attachment_ids: [uuid] (≤ PULSE_ATTACHMENT_MAX). After update_or_create, validate each against org + ownership + status="completed", then pulse.attachments.set(valid). On any invalid id → 400 {"attachment_ids": ["…"]} (reject the whole submit’s attachments; answers still persist — never lose the report). |
| GET | /api/pulses/today/ | IsOrgMember | Response PulseEntrySerializer gains attachments: [{id, public_url, content_type, filename, byte_size}] (read-only, from the M2M). |
| GET | /api/pulses/rollup/ & /rollup/<member_id>/ | leader gate (F103) | Serialize the same attachments[] for the leader read side (defined in F103 §5; F104 supplies the field). |
- Serializer: add a read-only
AttachmentSerializer(id, public_url, content_type, filename, byte_size) and nest it onPulseEntrySerializer.attachments(source = the M2M).PulseSubmitSerializergainsattachment_ids = ListField(child=UUIDField(), required=False, max_length=PULSE_ATTACHMENT_MAX). - Tenant scoping: the upload lookup is
Upload.objects.filter(organization_id=request.organization_id, id__in=attachment_ids, status="completed")— the explicit.filteridiom (noTenantQuerySetMixinin this repo). Ownership (user=request.membership) is an additional filter, never a replacement. The view performs this validation after the pulse upsert so answers are retained even when IDs are rejected. Mandatory cross-tenant + cross-member tests in §7. - AI / EvidenceSource: attachments carry their F34
EvidenceSource(source_type=uploaded_file). The extraction pass over the pulse may cite an attachment as evidence for an item (honest confidence, never 1.0); the pulse-levelEvidenceSource(source_type="pulse_entry")is unchanged. No fabricated evidence — a pulse with zero attachments simply serializesattachments: []. - Idempotency / edit: re-submitting the same day (
update_or_createalready updates in place) replaces the attachment set with the newattachment_ids(.set()semantics) so a member can add/remove before the day closes — matching how answers are replaced today.
6. UI / UX
- Component(s): the pulse form (
frontend/src/pages/.../PulseForm/PulsePage) gains an attachments row above submit. New sharedMultiFileAttachcomponent (extendsFileAttachButton, F34) —multipleinput, per-file thumbnail chips with progress + remove, honest per-file error. Wired viauseUpload(looped per file) so each file ispresign → S3 → completeand itsupload_idcollected for submit. - Mobile-first (hard requirement):
- The attach control is a primary, always-visible affordance on the pulse form (default path, not hidden behind a menu) — a big “Add photos / files” tap target above the submit button, thumb-reachable.
<input type="file" multiple accept="image/*,application/pdf,…">plus a camera affordance (capture="environment"on a dedicated “Take photo” button on mobile) so the OS camera opens directly.- Thumbnails are a horizontally-scrollable row on phone, a grid on desktop — a progressive enhancement of the same layout. Each chip: image preview or file-type icon, filename, size, spinner while uploading, an ✕ to remove, and a graceful file-type-chip fallback if a preview fails.
- Completed image thumbnails on the submitted pulse and leader report surfaces open an in-app full-image preview; non-image files and images without a usable URL retain the honest URL-gated fallback instead of showing a dead affordance.
- Previewable image cards keep the inline surface focused on the visual proof: the filename is not rendered as duplicate visible text, but remains in accessibility semantics and the full-image preview title. Non-image files and images without a usable preview URL retain their visible filename fallback.
- Uploads run in parallel with the user still typing answers; submit is disabled only while a file is mid-flight (with a clear “Uploading 2 photos…” label), never silently.
- shadcn + tokens:
Button,Card/chip,Progress,Skeleton,Badgefor file type — semantic Lumi tokens only, no raw palette (Rule 4). Checkfrontend/src/components/ui/first. Nowindow.confirmfor remove — inline ✕ (a remove-before-submit needs no modal; if a destructive confirm is ever added useuseConfirm). - States: empty (no attachments — the affordance still invites), uploading (per-file progress), attached (thumbnail chips), per-file error (inline, others survive), cap-reached (honest, add disabled), offline/slow (progress + removable stuck file).
- i18n: extend the existing
pulsenamespace (+ reuseuploads), keys added toen+vitogether then propagated to all 61 locales:pulse.add_photos,pulse.take_photo,pulse.attachments_label,pulse.attachment_uploading(Uploading {{n}}…),pulse.attachment_cap(Up to {{max}} files),pulse.attachment_remove,pulse.attachment_error(per-file), plus reuseuploads.error_too_large/uploads.error_invalid_type. - DESIGN.md / TASTE.md: the attach affordance must feel calm and inviting, not a dumping ground — one clear primary action, thumbnails that read as proof, honest progress. TASTE pre-flight: designed hover/focus/active on chips and buttons; keyboard: attach button focusable, remove reachable by keyboard, Esc does not lose typed answers.
- Assistant path (F91 doctrine): attaching is available in the conversation, not only on the form —
a user dropping/pasting images into the assistant pulse flow (
source=chat) attaches them through the sameuseUploadrail and the sameattachment_idscontract. The form is the optional direct path; the conversation reaches parity. No new required form field is introduced (attachments are optional by design), so form-parity holds. The pulse-submit action tool’s contract gains anattachment_idsparam (confirm-before-commit; attachments are never an external send, so no extra autonomy gate).
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Submit with N valid attachment_ids links all N to the pulse; today/rollup serialize them | backend | backend/tests/pulses/test_attachments.py::TestSubmitWithAttachments |
| 2 | Cross-tenant — an upload_id from org B is rejected (400), pulse answers still persist | backend | backend/tests/security/test_multitenancy_platform.py::TestMultimodalUploadIsolation.test_pulse_attachment_cross_tenant |
| 3 | Cross-member — another member’s upload id in my org is rejected (ownership guard) | backend | backend/tests/pulse/test_attachments_pulse.py::TestAttachmentOwnership |
| 4 | Non-completed (pending) upload id is rejected | backend | backend/tests/pulse/test_attachments_pulse.py::TestAttachmentOwnership |
| 5 | Over-cap (>PULSE_ATTACHMENT_MAX) rejected with honest error; at-cap accepted | backend | backend/tests/pulse/test_attachments_pulse.py::TestAttachmentCap |
| 6 | Re-submit same day replaces the attachment set (.set() semantics), no duplicates | backend | backend/tests/pulse/test_attachments_pulse.py::TestAttachmentReplace |
| 7 | Pulse with no attachments serializes attachments: [] (never null, never fabricated) | backend | backend/tests/pulse/test_attachments_pulse.py::TestSubmitWithAttachments.test_submit_with_no_attachments_serializes_empty |
| 8 | today/list serializers expose attachments without a null shape | backend | backend/tests/pulse/test_attachments_pulse.py::TestAttachmentQueryCount |
| 9 | Viewer cannot submit (existing gate) still holds with attachments present | backend | TestAttachmentRoles |
| 10 | MultiFileAttach uploads multiple files via presign→S3→complete, shows chips, removes one | frontend | frontend/src/test/pulses/MultiFileAttach.test.tsx (MSW) |
| 11 | Per-file error (too large / bad type) shows inline; other files still attach | frontend | same |
| 12 | Pulse form submits collected attachment_ids; submit blocked only while uploading | frontend | frontend/src/test/pulses/PulseFormAttachments.test.tsx |
| 13 | E2E: member attaches 2 photos to a pulse, submits, leader sees them in the rollup | e2e | frontend/tests/e2e/lumi.spec.ts describe("Pulse attachments (F104)"), incl. 390px mobile project |
| 14 | Android: submitted pulse and leader report open an image preview; unusable URL retains an honest fallback | Maestro | .maestro/critical/71_f104_attachment_preview.yaml (emulator mechanics only; named-device evidence remains required) |
| 15 | Previewable inline images show the thumbnail without duplicate visible filenames; accessibility and full-preview filename context remain; non-image/fallback rows retain filenames | shared KMP unit/source | android/shared/src/commonTest/kotlin/com/locuno/lumi/shared/ui/AttachmentPresentationTest.kt |
Backend coverage ≥97% (repo bar); F-spec target ≥99%. New E2E describe("Pulse attachments (F104)")
required, with a mobile-viewport assertion. pnpm test only (vitest lock); backend
DJANGO_SETTINGS_MODULE=config.settings.test.
8. Current Behavior
- Direct web pulse submission stores validated
PulseEntry.attachmentsand read serializers expose attachment metadata. The confirm-before-commit assistantsubmit-pulsepath now accepts the same member-owned completedattachment_ids; Telegram media ingestion remains unimplemented. - The upload rail is live and reused elsewhere:
useUpload(frontend/src/hooks/useUpload.ts),FileAttachButton(frontend/src/components/uploads/FileAttachButton.tsx, single-file, 10 MB, MIME allowlist), backed by F34’s presign/complete endpoints and CDN. Chat, tasks, comments, and the account avatar already flow through it — the pulse is the conspicuous gap. - Consequence: field evidence still lives in external chat apps when the attachment path is not wired; the faithfulness graph (F86) cannot point an extracted item at the photo behind it in those paths.
9. Error States
- Invalid attachment id (cross-org / cross-member / not completed / unknown):
400 {"attachment_ids":[...]}; the pulse’s answers still save — attachments are additive and never block the report. Logged atwarning(lumi.pulses.attachments) without leaking whether the upload exists. - File too large / disallowed type: caught client-side (F34 limits) with an inline per-file message
(
uploads.error_too_large/uploads.error_invalid_type); the presign endpoint is the server backstop (400). Other files in the batch are unaffected. - Upload fails mid-flight (S3 CORS/403/network): that chip shows an error + retry/remove; submit stays enabled for the successfully-attached files; the user never loses typed answers.
- Over cap: honest inline message (
pulse.attachment_cap), add disabled — never a silent drop. - Attachment thumb fails to render (rollup or submitted view): graceful fallback to a file-type chip (mirrors F103 §9) — never a broken image, never blocks the card.
- Slow connection: per-file progress; submit shows “Uploading N…” and re-enables when in-flight uploads settle — no spinner-forever.
10. Diagrams & Docs Touched
backend/DIAGRAMS.md— ER: add thePulseEntry —M2M→ Uploadedge (attachments); note the submit-time validation step in the pulse request lifecycle. No new state machine.frontend/DIAGRAMS.md— note the pulse form’s attachments sub-flow (useUploadloop →attachment_ids).GLOSSARY.md— add “Pulse Attachment” (anUploadlinked to aPulseEntry; carries its ownEvidenceSource).docs/specs/done/F34-user-uploads.md— add a “Consumers” note that the pulse (F104) is now an upload consumer (keep F34’s own status; append, don’t rewrite).docs/MVP_SCOPE.md— new F104 matrix row (this PR).docs/tests/web/web-screen-audit.md— the pulse screen row gains attachment coverage in its next audit.- ADRs: none (reuses F34 upload rail, F07/F83 pulse, F86 evidence graph). No structural decision.
11. Implementation Audit (2026-07-10)
Built (backend + web frontend). Android now has the first native vertical slice: the shared pulse contract accepts attachment ids, the model uses the F34 upload rail, and the Android pulse form exposes multi-file selection plus direct camera capture. The iOS picker/host bridge is now wired through the native host; native thumbnail/open proof and fresh device verification remain open. Constraints the design must respect:
submit_pulseisupdate_or_create(views.py:150) keyed on(org, member, date)— attachments must be.set()after the pulse row exists (M2M needs a PK), and re-submit must replace, not append. Put the.set()afterupdate_or_create, inside the same request, best-effort-guarded like the existing evidence/metrics blocks so an attachment glitch never loses answers — but an invalid id is a hard400(that’s user error, not a system glitch), raised before the answer-losing path via serializer validation.Upload.userisSET_NULL, null=True(F34 §4) — ownership check must handle a nulluser(treat as not-owned → reject) rather than assuming a member.- No
TenantQuerySetMixin— use explicit.filter(organization_id=...); do not import an aspirational mixin. FileAttachButtonis single-file and usesalert()for errors — F104’sMultiFileAttachmust not regress toalert(); use inline token-styled messaging (TASTE). KeepFileAttachButtonfor its existing single-file consumers; add the multi variant beside it rather than breaking callers.- F103 depends on this field’s shape — ship the serializer
attachments[]contract (id, public_url, content_type, filename, byte_size) exactly as F103 §5 consumes it, or the two specs drift.
12. Open Questions / Decisions
- Cap value. Proposed
PULSE_ATTACHMENT_MAX = 10— small enough that a rollup card and the CEO Brief stay scannable, generous for a field day. Confirm; trivially raised later (no schema change). - Attach to Upload vs. EvidenceSource. Decision: link to
Upload(rendering needs its file fields; theEvidenceSourcealready exists per upload for provenance). Linking toEvidenceSourceinstead would force a join toUploadfor every thumbnail. No ADR (implementation detail). - Telegram (F21) ingestion depth. The data contract (upload rail →
attachment_ids) is identical; the bot-side media download/wiring is F21’s surface. Proposed: land web + chat (F91) parity in v1, Telegram media as a fast-follow tracked in F21. Confirm scope. - Editing after the day closes. Re-submit replaces the set while the day is open (matches answer-edit). Whether a processed pulse can still gain a photo (post-extraction) is deferred — v1: attachments are set set at submit time, like answers.
- CDN thumbnail sizing (
?w=). Use fullpublic_urlin v1; a CDN resize param is a fast-follow for data savings on a$150 Android, not a new pipeline (F34 §3 keeps image processing out).
13. Bug / Change Log
2026-08-29 — Previewable Pulse images no longer repeat filenames (lumi-fb #132, #135)
Type: Native shared-KMP UI refinement (F104 read and submitted surfaces)
Summary:
- Previewable image attachments on the submitted Pulse, Team overview, and report detail cards now show the thumbnail without a duplicate visible filename.
- The filename remains attached to the thumbnail/card accessibility semantics and full-image preview title, so the change removes visual noise without removing identification for assistive technology or the open action.
- Non-image files and image rows without a usable preview URL keep their visible filename and metadata fallback.
Verification: cd android && ./gradlew :shared:jvmTest --tests com.locuno.lumi.shared.ui.AttachmentPresentationTest -PlumiFilteredJvmTest --no-daemon; git diff --check
2026-08-25 — Pulse Add photos opens the iOS Photos library (lumi-fb #105)
Type: iOS native picker correction (F104 capture side)
Summary:
- The Pulse Add photos action now uses SwiftUI PhotosPicker with image matching and multi-selection instead of opening the Files document importer.
- Camera capture remains a separate native path; Task and Message document attachment importers are unchanged.
- Focused XCTest source proof covers PhotosUI, image matching, selection state, and the absence of the old file importer on this action.
Evidence boundary:
- The local source/runtime proof establishes picker wiring. Exact TestFlight/Play artifacts and reporter-device album behavior remain separate release and acceptance evidence.
2026-07-13 — Native local picker preview URIs
Type: Android + iOS shared-KMP UI refinement (F104 capture side)
Summary:
- Android library selections retain their authorized content URI and camera captures are copied into the app cache before the capture URI is deleted; iOS file imports and camera captures use a temporary file URL for the same immediate preview path.
- The preview URI now travels through
UploadSelectionandPulseAttachmentDraft, so image draft cards can render the native thumbnail before the server upload completes. The upload payload remains bytes-only; the URI is presentation metadata and is never sent to the API. - Full-image presentation, rendered named-device proof, and cleanup/long-lived preview behavior remain open; the mobile audit scores stay unchanged until those gates are verified.
Verification: cd android && ANDROID_HOME=/Users/james/Library/Android/sdk ./gradlew :shared:jvmTest :app:compileProdDebugKotlin :shared:compileKotlinIosSimulatorArm64 --no-daemon; git diff --check
2026-07-13 — Native completed-image thumbnail decoder
Type: Android + iOS shared-KMP UI implementation (F104 read side)
Summary:
- Completed image attachments on the submitted pulse and F103 rollup/detail now load a
thumbnail from the existing CDN
public_urlthrough a shared Compose expect/actual seam. - Android uses
BitmapFactory; iOS uses the existing Compose Skia graphics layer. A loading state is shown while bytes are read, and decode/network failure leaves the metadata and URL-gated open fallback intact. - Non-image files, full-screen image presentation, and named-device proof remain open. The mobile audit scores stay unchanged until rendered evidence exists.
Verification: cd android && ANDROID_HOME=/Users/james/Library/Android/sdk ./gradlew :shared:jvmTest --no-daemon; cd android && ANDROID_HOME=/Users/james/Library/Android/sdk ./gradlew :shared:compileAndroidMain :shared:compileKotlinIosSimulatorArm64 --no-daemon; git diff --check
2026-07-13 — Native attachment metadata and open-state slice
Type: Android + iOS shared-KMP UI refinement (F104)
Summary:
- Submitted pulse attachments now show filename, MIME type, byte size, and an explicit
Openaction only when the server returned a non-emptypublic_url. - The native reports rollup/detail uses the same metadata and reports a localized
unavailable-preview state instead of exposing a dead
Openaffordance. - Draft attachment cards retain byte size and show an indeterminate per-file progress bar while the existing F34 upload is in flight. The upload interface has no byte-level callback, so this slice does not fabricate a percentage.
- Thumbnail decoding, full-image rendering, and named-device proof remain open; this change intentionally does not change the mobile audit scores.
Verification: cd android && ANDROID_HOME=/Users/james/Library/Android/sdk ./gradlew :shared:jvmTest --no-daemon; cd android && ANDROID_HOME=/Users/james/Library/Android/sdk ./gradlew :app:compileProdDebugKotlin :shared:compileKotlinIosSimulatorArm64 --no-daemon; git diff --check
2026-07-14 — Native full-image preview
Type: Android + iOS + macOS shared-KMP UI refinement (F104 read side)
Summary:
- Completed image attachments on the submitted pulse and F103 report/detail surfaces now open an in-app preview
dialog when the CDN
public_urlis available. - The preview uses a fit-scaled image so the full frame remains visible, keeps a localized close action, and retains the existing URL open action for download/share or non-image files.
- This is source-level completion only; named-device rendering and PM audit evidence remain open, so no audit score changes in this PR.
Verification: cd android && ./gradlew :shared:jvmTest :app:compileProdDebugKotlin :shared:compileKotlinIosSimulatorArm64 --no-daemon; bash scripts/check-platform-parity.sh; git diff --check
2026-07-14 — Desktop preview parity and accessibility hooks
Type: JVM/macOS shared-KMP UI refinement (F104 read side)
Summary:
- The JVM/macOS
AttachmentThumbnailactual now decodes local files and CDN URLs with the same fit-scaled image path as Android and iOS; it no longer silently drops the preview surface. - The shared preview dialog exposes stable test tags for the dialog, close action, and URL open action, and the rendered image carries the attachment filename as its accessibility description.
- Native device rendering, live CDN delivery, and PM audit scoring remain separate evidence gates.
Verification: git diff --check
2026-07-13 — Android native pulse attachment vertical slice
Type: Android shared-KMP parity slice (F104)
Summary:
PulseEntryResponsenow consumes the F104attachments[]read contract and pulse submit sends optionalattachment_idsthrough the existing F34 presign/complete rail.- The shared Android pulse model uploads selected files, supports remove/retry, enforces the ten-file cap, and preserves completed upload ids through the offline submit queue.
- Android’s pulse form now exposes a multi-file picker and direct camera capture. This is deliberately a partial native slice: iOS host/picker wiring, rendered thumbnail/open behavior, and device proof remain pending.
Verification: cd android && ./gradlew :shared:jvmTest :app:compileProdDebugKotlin; git diff --check
2026-07-12 — iOS native pulse attachment host and picker wiring
Type: iOS shared-KMP parity slice (F104)
Summary:
PulseViewControllerHostnow exposes the shared F34 upload rail to SwiftUI, and the iOS pulse route uses that host rather than the generic screen bridge.- iOS supports multiple file import and direct camera capture; selected data is converted
to shared
UploadSelectionvalues so the existing MIME/size/cap rules remain single-source. - Native thumbnail/open rendering, per-file progress, and fresh device verification remain open.
Verification: cd android && ./gradlew :shared:jvmTest :shared:compileKotlinIosSimulatorArm64; focused iOS simulator Swift type-check against the generated shared framework. Full xcodebuild/device proof remains environment-blocked.
2026-07-14 — Backend + web frontend implemented
Type: Implementation (backend + web frontend; android/ios deferred per task scope)
Summary:
- Added
PulseEntry.attachmentsM2M touploads.Upload(migration 0011) PULSE_ATTACHMENT_MAX = 10constant inapps/pulses/constants.pyAttachmentSerializer(id, public_url, content_type, filename, byte_size)PulseSubmitSerializer.attachment_ids— optional UUID list, capped at 10submit_pulsevalidates each attachment against org + ownership + completed status; returns 400 on invalid.set()semantics on re-submit replaces attachments; omitting attachment_ids clears themMultiFileAttachcomponent — multiple file input, camera capture, per-file thumbnails with progress and inline errors, cap enforcement- Integrated into
PulsePage— attachments above submit, disabled while uploading - i18n keys in all 61 locales (en fallback), added to strategic-locale allowlist
- 13 backend tests, 3 frontend MSW tests, 2 E2E tests
- MVP_SCOPE row: backend/web source and focused tests are present; browser/native/live-storage and assistant/Telegram parity remain partial in the Contract Graph.
2026-07-14 — Contract Graph migration
Type: Contract and evidence audit
Summary:
- Added the canonical
contracts/features/F104.cuecontract for backend linking/serialization, validation boundaries, web capture, native source paths, and the still-unimplemented assistant/ Telegram parity requirement. - Registered backend and web proof IDs; added the missing cross-tenant marker without claiming live storage, browser, or native device evidence.
- Corrected this spec’s test paths and implementation wording to match the shipped view and tests.
Verification: make contract-validate, make contract-plan, make contract-verify,
make check-docs, and git diff --check; generated counts are recorded in the F104 audit.
2026-07-10 — Spec created
Type: Documentation Change
Summary:
- New spec F104 defining multiple photo/file attachments on the daily pulse, on by default and
mobile-first. Adds one
PulseEntry.attachmentsM2M to the existing F34Upload;submit_pulseaccepts validatedattachment_ids; serializers exposeattachments[]for the submitted view, the F91 chat path, and the F103 leader rollup. Reuses the F34 upload rail (presign/S3/complete/CDN) and its 10 MB + MIME limits; no new upload mechanism, no new table.
Reason:
- User request (2026-07-10): “report/pulse should also have multiple photos/files upload options by default; everything must be mobile first.” The pulse — the most-used surface — was the one upload consumer missing, so field evidence leaked into external chat apps and leaders read claims without proof.
Impact:
- Additive M2M migration on
PulseEntry;submit_pulsegains optionalattachment_ids; pulse serializers gain read-onlyattachments[]; a newMultiFileAttachcomponent + camera/multi-select affordance on the pulse form. No change to how text answers are captured. Enables F103 to render attachment thumbnails.
Tests:
- To be added per §7 (submit-with-attachments; cross-tenant/cross-member/not-completed/ cap/replace/empty/ query-count/role; frontend multi-upload + per-file error + submit gating; e2e attach→submit→rollup incl. mobile viewport).
Notes:
- Link to
Upload(notEvidenceSource) for rendering; provenance rides on the upload’s existing F34EvidenceSource..set()afterupdate_or_create; invalid ids are a hard400from the submit view after the pulse row is upserted (answers still persist). Do not regress toalert()— inline token-styled errors. Ship theattachments[]serializer shape exactly as F103 §5 consumes it.