draft

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/*" capture affordance) 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.
  • 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.)
  • 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.
  • 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_id from org B or another member’s private upload, then submit rejects it (400/403) — attachments are validated against the caller’s org + ownership.

Every criterion maps to a test in §7.

3. Scope Boundaries

  • In:
    • A many-to-many link from PulseEntry to uploads.Upload (the F34 upload) so a pulse carries N files.
    • Multi-file / photo attach by default on the pulse form (PulseForm), reusing useUpload + a new multi-file variant of FileAttachButton.
    • submit_pulse accepts attachment_ids and .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).
  • 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_url directly; 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 needs public_url, content_type, filename — all on Upload (F34 §4). A leader’s rollup card (F103 §5) reads exactly these. The EvidenceSource (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_id must satisfy organization_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 = 10 per pulse (constant in apps/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 via makemigrations (never edit an applied one). related_name="pulses" on Upload lets F103 prefetch attachments in 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.

MethodPathAuth/RoleChange
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/IsOrgMemberResponse 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 on PulseEntrySerializer.attachments (source = the M2M). PulseSubmitSerializer gains attachment_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 .filter idiom (no TenantQuerySetMixin in 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-level EvidenceSource(source_type="pulse_entry") is unchanged. No fabricated evidence — a pulse with zero attachments simply serializes attachments: [].
  • Idempotency / edit: re-submitting the same day (update_or_create already updates in place) replaces the attachment set with the new attachment_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 shared MultiFileAttach component (extends FileAttachButton, F34) — multiple input, per-file thumbnail chips with progress + remove, honest per-file error. Wired via useUpload (looped per file) so each file is presign → S3 → complete and its upload_id collected 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, Badge for file type — semantic Lumi tokens only, no raw palette (Rule 4). Check frontend/src/components/ui/ first. No window.confirm for remove — inline ✕ (a remove-before-submit needs no modal; if a destructive confirm is ever added use useConfirm).
  • 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 pulse namespace (+ reuse uploads), keys added to en + vi together 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 reuse uploads.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 same useUpload rail and the same attachment_ids contract. 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 an attachment_ids param (confirm-before-commit; attachments are never an external send, so no extra autonomy gate).

7. Test Plan

#CaseTypeWhere
1Submit with N valid attachment_ids links all N to the pulse; today/rollup serialize thembackendbackend/tests/pulses/test_attachments.py::TestSubmitWithAttachments
2Cross-tenant — an upload_id from org B is rejected (400), pulse answers still persistbackendbackend/tests/security/test_multitenancy_platform.py::TestMultimodalUploadIsolation.test_pulse_attachment_cross_tenant
3Cross-member — another member’s upload id in my org is rejected (ownership guard)backendbackend/tests/pulse/test_attachments_pulse.py::TestAttachmentOwnership
4Non-completed (pending) upload id is rejectedbackendbackend/tests/pulse/test_attachments_pulse.py::TestAttachmentOwnership
5Over-cap (>PULSE_ATTACHMENT_MAX) rejected with honest error; at-cap acceptedbackendbackend/tests/pulse/test_attachments_pulse.py::TestAttachmentCap
6Re-submit same day replaces the attachment set (.set() semantics), no duplicatesbackendbackend/tests/pulse/test_attachments_pulse.py::TestAttachmentReplace
7Pulse with no attachments serializes attachments: [] (never null, never fabricated)backendbackend/tests/pulse/test_attachments_pulse.py::TestSubmitWithAttachments.test_submit_with_no_attachments_serializes_empty
8today/list serializers expose attachments without a null shapebackendbackend/tests/pulse/test_attachments_pulse.py::TestAttachmentQueryCount
9Viewer cannot submit (existing gate) still holds with attachments presentbackendTestAttachmentRoles
10MultiFileAttach uploads multiple files via presign→S3→complete, shows chips, removes onefrontendfrontend/src/test/pulses/MultiFileAttach.test.tsx (MSW)
11Per-file error (too large / bad type) shows inline; other files still attachfrontendsame
12Pulse form submits collected attachment_ids; submit blocked only while uploadingfrontendfrontend/src/test/pulses/PulseFormAttachments.test.tsx
13E2E: member attaches 2 photos to a pulse, submits, leader sees them in the rollupe2efrontend/tests/e2e/lumi.spec.ts describe("Pulse attachments (F104)"), incl. 390px mobile project
14Android: submitted pulse and leader report open an image preview; unusable URL retains an honest fallbackMaestro.maestro/critical/71_f104_attachment_preview.yaml (emulator mechanics only; named-device evidence remains required)
15Previewable inline images show the thumbnail without duplicate visible filenames; accessibility and full-preview filename context remain; non-image/fallback rows retain filenamesshared KMP unit/sourceandroid/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.attachments and read serializers expose attachment metadata. The confirm-before-commit assistant submit-pulse path now accepts the same member-owned completed attachment_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 at warning (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 the PulseEntry —M2M→ Upload edge (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 (useUpload loop → attachment_ids).
  • GLOSSARY.md — add “Pulse Attachment” (an Upload linked to a PulseEntry; carries its own EvidenceSource).
  • 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_pulse is update_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() after update_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 hard 400 (that’s user error, not a system glitch), raised before the answer-losing path via serializer validation.
  • Upload.user is SET_NULL, null=True (F34 §4) — ownership check must handle a null user (treat as not-owned → reject) rather than assuming a member.
  • No TenantQuerySetMixin — use explicit .filter(organization_id=...); do not import an aspirational mixin.
  • FileAttachButton is single-file and uses alert() for errors — F104’s MultiFileAttach must not regress to alert(); use inline token-styled messaging (TASTE). Keep FileAttachButton for 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

  1. 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).
  2. Attach to Upload vs. EvidenceSource. Decision: link to Upload (rendering needs its file fields; the EvidenceSource already exists per upload for provenance). Linking to EvidenceSource instead would force a join to Upload for every thumbnail. No ADR (implementation detail).
  3. 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.
  4. 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.
  5. CDN thumbnail sizing (?w=). Use full public_url in 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 UploadSelection and PulseAttachmentDraft, 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_url through 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 Open action only when the server returned a non-empty public_url.
  • The native reports rollup/detail uses the same metadata and reports a localized unavailable-preview state instead of exposing a dead Open affordance.
  • 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_url is 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 AttachmentThumbnail actual 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:

  • PulseEntryResponse now consumes the F104 attachments[] read contract and pulse submit sends optional attachment_ids through 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:

  • PulseViewControllerHost now 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 UploadSelection values 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.attachments M2M to uploads.Upload (migration 0011)
  • PULSE_ATTACHMENT_MAX = 10 constant in apps/pulses/constants.py
  • AttachmentSerializer (id, public_url, content_type, filename, byte_size)
  • PulseSubmitSerializer.attachment_ids — optional UUID list, capped at 10
  • submit_pulse validates each attachment against org + ownership + completed status; returns 400 on invalid
  • .set() semantics on re-submit replaces attachments; omitting attachment_ids clears them
  • MultiFileAttach component — 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.cue contract 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.attachments M2M to the existing F34 Upload; submit_pulse accepts validated attachment_ids; serializers expose attachments[] 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_pulse gains optional attachment_ids; pulse serializers gain read-only attachments[]; a new MultiFileAttach component + 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 (not EvidenceSource) for rendering; provenance rides on the upload’s existing F34 EvidenceSource. .set() after update_or_create; invalid ids are a hard 400 from the submit view after the pulse row is upserted (answers still persist). Do not regress to alert() — inline token-styled errors. Ship the attachments[] serializer shape exactly as F103 §5 consumes it.