shipped

Spec: F07 — Daily Pulse

Status: shipped · Audited: 2026-06-10

1. Problem & CEO Job

The wedge — everything downstream depends on this. Six honest sentences per person per day replace the standup the CEO cannot attend and the status report nobody writes. It must take under two minutes and never feel like surveillance, or participation stops and the Brief starves; a first-time warehouse worker must finish it on a phone without training.

2. User Stories & Acceptance Criteria

  • As a CEO, I want to replace the daily standup I cannot attend (or that no one writes honestly) with a format the whole org can do in under two minutes, so that I get honest, traceable operating intelligence without changing anyone’s routine.
    • What it replaces: Today the owner gets status the only way the team will give it — scattered Zalo/WhatsApp messages and a verbal “anh ơi xong chưa?” — unstructured, unrecorded, and impossible to act on without re-reading every thread. A Zalo status message scrolls away; nothing pulls the commitment, the risk, or the decision out of it. Lumi’s pulse is the opposite: six fixed questions (this §, below), each mapped to an extraction target, so the owner never re-reads raw chat — they read extracted Commitments, Risks, and DecisionRequests (F08).
    • Given the form, then all questions are free-text, all optional — blank answers are valid; a single answered question is a valid submission.
    • Given I already submitted today, when I open /intel/pulse, then I see my submission (read-back), not an empty form — one pulse per person per day (DB-enforced).
  • As a team member, I want my daily answers to produce visible, traceable signals in the Brief without me writing a report, so that the habit sustains without extra effort.
    • What it replaces: A Zalo group message records what someone typed, then buries it under the next fifty messages; an Excel “daily report” tab, where one exists at all, records a row no one extracts meaning from. Neither turns an update into Commitments, Risks, or Decisions — the owner must re-read the thread and decide what matters. Lumi extracts items (F08) and attributes them to the member with EvidenceSource links, so the member’s pulse is the single input that feeds the Brief, Commitment, Risk, and Decision pipelines. Without this feedback loop, the pulse habit starves.
    • Given a submitted pulse, when processed, then extracted items (F08) are attributed to me and my team, traceable via EvidenceSource.
  • As a CEO, I want to see at a glance who is in the pulse habit and who is not, so that I fix the behaviour gap before the Brief starves — not chase individuals one by one.
    • What it replaces: A Zalo group shows who happened to message today, but not habit history — which members go quiet on which days of the week, and the owner is left chasing each person by hand (“anh ơi báo cáo đi”). Lumi tracks per-member pulse history × working-day cadence, and surfaces adherence gaps that correlate with Brief signal degradation. The owner’s diagnostic question becomes “is the pulse supply chain healthy?” not “did thi công report today?”
    • Given today’s date, pulse list shows submissions; gaps are visible.

The six questions (fixed, Vietnamese-first — DEFAULT_PULSE_QUESTIONS)

  1. Hôm nay bạn hoàn thành việc gì quan trọng?
  2. Bạn đang kẹt ở đâu?
  3. Bạn đã hứa gì với khách / team / nhà cung cấp?
  4. Việc nào cần CEO hoặc quản lý quyết?
  5. Có rủi ro nào về khách, doanh thu, công nợ, đơn hàng hoặc deadline không?
  6. Ngày mai việc quan trọng nhất của bạn là gì?

Each maps to an extraction target (2→risk/blocked, 3→commitment, 4→decision, 5→risk). Changing questions = changing the AI contract → requires updating F08 in the same PR.

3. Scope Boundaries

  • In: web form, Telegram entry (F21), today/submit/list/process endpoints, EvidenceSource creation, per-day uniqueness.
  • Out: custom per-org questions (MVP keeps the extraction contract stable), reminders/nags (Telegram daily prompt covers it), manager review/approval of pulses (it’s a trust tool, not a control tool).

Competitive note — the Excel “daily report” tab and owner memory. Where a Vietnamese SME has any written status habit at all beyond chat, it is a shared spreadsheet — a manual-entry tab that records rows but produces no structured signal unless the owner builds formulas, rollups, and relations they will never build, because the value is not obvious until it is built. The common case is even thinner: status lives in owner memory, reconstructed by asking each person. Both produce notes the owner must mentally filter; Lumi’s pulse produces evidence-linked operating intelligence — Commitments, Risks, and DecisionRequests extracted from each answer, traceable to the person who said it, persisting in the Brief. The difference is not the form; it is what happens after the submit button is pressed.

Competitive note — WhatsApp/Zalo groups. Vietnamese SME owners currently get daily status by reading WhatsApp or Zalo group messages — unstructured, unrecorded, invisible to anyone who wasn’t in the thread. Lumi’s fixed six-question pulse (§2) replaces this with a structured, phone-first form that captures every answer as traceable evidence. The feedback loop is the difference: a Zalo status message vanishes; a pulse submission creates evidence-linked Commitments, Risks, and DecisionRequests (via F08) that persist in the Brief. The real first-market competitor is Zalo/WhatsApp + Excel + owner memory — not any US check-in feature — and the win is treating the daily check-in as a first-class intelligence source rather than a message that scrolls away.

Relation to the F70 wedge. The pulse is the steady-state capture habit. It assumes a team already in Lumi submitting six sentences a day — which is exactly what a cold owner does not have on day one. The cold-start is F70 (Chaos Import → Boss Brief): the owner pastes one messy Zalo/Excel dump and gets a brief in their first session, before anyone is in the pulse habit. F70 is the wedge; the daily pulse is the habit it graduates into once staff are onboarded (Loop 1).

4. Data Model

  • pulses.PulseEntry — status: draft → submitted → processed | failed; raw_text (joined answers), answers_json, unique (org, user, date).
  • pulses.EvidenceSource — the provenance anchor for all AI items; source_type=pulse_entry, content_text from the pulse.

5. API Contract

MethodPathMin roleNotes
GET/api/pulses/today/memberMy pulse for today (200 not_submitted if none — not 404)
POST/api/pulses/submit/memberSubmit answers; creates EvidenceSource; auto-triggers extraction. Accepts optional idempotency_key (UUID) — same key + same org+user → returns existing pulse without re-extraction (issue #1647)
GET/api/pulses/viewerList (read-only ViewSet)
POST/api/pulses/<id>/process/memberIdempotent retry for failed entries
CRUD/api/pulses/evidence-sources/memberTenant-scoped evidence records; the Contract Graph declares list, retrieve, create, update, and delete operations explicitly

6. UI / UX

  • /intel/pulse (PulsePage.tsx). One screen, six labeled textareas, one submit. No wizard, no progress bar theatrics.
  • States: not-yet-submitted (form), already-submitted (read-back + “Đã gửi ✓”), submitting, error (actionable toast, answers preserved — never lose typed text).
  • Mobile-first: this is the one page used by every employee, mostly on phones.
  • Leader-only Team check-ins card above the form (gated on request.membership.role ∈ {owner, admin, manager}): badge with submitted/total, success state when all done, otherwise the list of missing members. Carries a one-tap Share affordance that turns the day’s adherence into a chat-pasteable artifact ({org} · Lumi Pulse · {date} · ✅ count line · missing list · F59 referral footer) — same distribution-object hook the Brief and Today surfaces use (buildPulseAdherenceShareText + usePulseAdherenceShare, mirrors buildBriefShareText + useBriefShare). The button is hidden on loading / error / missing-data paths so there is never a share affordance without a concrete artifact to share.
  • i18n: pulse.

7. Test Plan

#CaseTypeWhere
1Submit happy path; resubmit same day overwrites the same-day entry and returns 200 on repeat submitbackendtests/test_api.py::TestPulse, tests/test_lumi.py::TestPulseSubmission
2Submit creates EvidenceSource with pulse textbackendtests/test_api.py::TestPulse
3Submit auto-triggers extraction; failure sets failed; /process/ retries failed pulses idempotentlybackendtests/test_api.py::TestPulse
4Form fill + submit + read-back; failed shows retry buttone2edescribe("Daily Pulse")
5Team check-ins share artifact is deterministic, carries org name when present, plural-aware missing list, attributed footer when ?ref= availablefrontendfrontend/src/test/pulseAdherenceShare.test.ts::buildPulseAdherenceShareText
6Pulse share hook uses native navigator.share first, falls back to clipboard, treats AbortError as no-op, surfaces toast on success/errorfrontend⚠️ test not present — usePulseAdherenceShare.test.tsx does not exist; hook behaviour is partially covered by frontend/src/test/TeamCheckInsCard.test.tsx (row 7); frontend/src/test/pulseAdherenceShare.test.ts covers the builder only
7Leader card renders a Share button when adherence data is present and clicking it invokes the hook; hidden on loading/error/null-data pathsfrontendfrontend/src/test/TeamCheckInsCard.test.tsx
8Submit with idempotency_key — same key + same org+user returns existing pulse without re-extraction (one Commitment, not two)backendbackend/tests/api/test_pulses.py::TestPulse::test_submit_with_idempotency_key_returns_same_on_repeat
9Different idempotency keys for same org+user+date create independent submissionsbackendbackend/tests/api/test_pulses.py::TestPulse::test_submit_with_idempotency_key_different_keys_are_independent
10Same idempotency key in different orgs does not collidebackendbackend/tests/api/test_pulses.py::TestPulse::test_submit_idempotency_key_tenant_isolation
11Omitting idempotency_key still works (backward compat with web forms)backendbackend/tests/api/test_pulses.py::TestPulse::test_submit_without_idempotency_key_still_works
12KMP submit generates and passes an idempotency key to the repositorysharedandroid/shared/src/commonTest/.../PulseScreenModelTest.kt — submit passes an idempotency key to the repository
13KMP flushQueue passes stored idempotency key on retrysharedandroid/shared/src/commonTest/.../PulseScreenModelTest.kt — flushQueue passes stored idempotency key on retry
14Queued submission persists idempotency key in PendingPulseSubmissionsharedandroid/shared/src/commonTest/.../PulseScreenModelTest.kt — queued submission persists idempotency key in PendingPulseSubmission

8. Current Behavior

Shipped. The six-question pulse form (PulsePage.tsx), submission endpoint (POST /api/pulses/submit/), evidence capture (EvidenceSource creation), and downstream extraction pipeline all exist. Submit auto-triggers extraction (2026-06-10 fix); extraction failures are caught — the pulse is saved with status="failed" and a retry button is shown. The draft status exists in the model but is unused by the web flow (the Telegram staged-answering flow uses TelegramConversation state, not draft pulse records). Submission is intentionally resilient: answers are never lost even when enrichment fails.

Known gap — pulse questions are not localised. DEFAULT_PULSE_QUESTIONS is a hardcoded Python list of Vietnamese strings. Users in Malaysia, Saudi Arabia, or other target markets see questions in Vietnamese. Fixing this requires changing the AI extraction contract (see §11 audit row 11 and §12 open question).

9. Error States

#ErrorBehaviorCode reference
ES1AI extraction failure on submit — extract_pulse_entry raises during submit.Pulse saved with status="failed", exception logged. Submit still returns 201. Frontend shows retry button. Answers never lost.views.py:63–67 try/except; test_api.py::TestPulse::test_submit_extraction_failure_sets_failed
ES2Retry failed pulse via /process/ — POST /api/pulses/<id>/process/ on a failed pulse.Retries extraction; on success: status→"processed", items created. On failure: status="failed", error_message set, returns 200 (consistent with submit).views.py PulseViewSet.process; test_api.py::TestPulse::test_process_retries_failed_pulse; test_red_r18.py::TestF08ExtractionFailure::test_process_endpoint_sets_failed_on_error
ES3Re-processing already-processed pulse — /process/ on status="processed" entry.Idempotent: returns 200 with empty extraction lists; no duplicate items created.services.py:87–89; test_api.py::TestPulse::test_process_idempotent_on_processed
ES4No EvidenceSource found for /process/ — no source_type=pulse_entry record for this user.evidence is None; extract_pulse_entry creates items without evidence links rather than erroring.views.py:80–86 None fallback; services.py:93–99
ES5today_pulse when no pulse exists — GET /api/pulses/today/ on a day with no submission.Returns 200 {"questions": DEFAULT_PULSE_QUESTIONS, "date": today, "status": "not_submitted"} — not a 404. (§5 originally said “or 404 → show form” — code returns 200.)views.py:28–30 except DoesNotExist path
ES6Unauthenticated / not-org-member — requests without auth or org membership.Unauthenticated → 401. Authenticated without org membership → 403.views.py:22, 33 @permission_classes([IsAuthenticated, IsOrgMember])
ES7/process/ extraction exception — extraction raises on retry attempt.Wraps in try/except: logs exception, sets pulse.status="failed" with error_message, returns 200 instead of 500.test_red_r18.py::TestF08ExtractionFailure::test_process_endpoint_sets_failed_on_error
ES8Cross-tenant access — user from org B calls GET /api/pulses/.Returns empty list (not 403) — queryset filters by organization_id. Same for today_pulse.views.py:72–75; test_api.py::TestPulse::test_pulse_tenant_isolation

10. Diagrams & Docs Touched

DIAGRAMS.md AI pipeline · docs/AI_PIPELINE.md · GLOSSARY Pulse terms.

11. Implementation Audit (2026-06-14)

#ClaimStatusEvidence
1Submit auto-triggers extraction; /process/ is idempotent retry for failed✅ match2026-06-10 fix; test_api.py::TestPulse::test_submit_extraction_failure_sets_failed
2status=failed on extraction exception; submit returns 201; retry button shown in UI✅ matchviews.py:63–67 try/except; PulsePage.tsx retry affordance
3draft status: model has it, web flow never uses it — documented, not removed✅ matchmodels.py:7; views.py:47 always sets "submitted"
4EvidenceSource created on submit with pulse text (source_type=pulse_entry)✅ matchviews.py submit path; test_api.py::TestPulse
5Slot-based confidence scoring: preferred slots (q3/q4/q5) → 0.75; other slots → 0.6✅ matchapps/ai/services.py inline at ~922 (commitment), ~965 (risk), ~1008 (decision) — no _compute_confidence function exists; confidence is assigned inline per extraction loop
6At most one item per type per pulse: break after first keyword match✅ matchapps/ai/services.py:955, 1039; multi-commitment pulses silently drop later mentions
7Company name auto-linking on commit/risk extraction✅ matchapps/ai/services.py detect_company_in_text calls after each Commitment/Risk creation
8Resubmission same day: update_or_create silently overwrites and returns 200 on repeat submit✅ matchviews.py:47–60; test_api.py 201 first then 200 repeat; §7 corrected 2026-07-02
9today_pulse returns 200 {"status": "not_submitted"} — not 404✅ matchviews.py:28–30; §5 corrected 2026-06-22
10Date parameter not validated against today — past/future dates accepted⚠️ gapserializers.py:18 accepts any date; views.py:34 uses client-supplied date; no test for future-date pulse
11DEFAULT_PULSE_QUESTIONS — hardcoded Vietnamese; international orgs see Vietnamese questions✅ fixed (F83)F83 replaces hardcoded questions with a single free-text textarea + role/industry-aware hint chips. DEFAULT_PULSE_QUESTIONS remains as a legacy fallback in today_pulse for orgs with no resolved profile, but usePulseToday() is defined in usePulses.ts and never imported by any component — PulsePage reads usePulseProfile() for hint chips and usePulsesList() for history. The .questions field emitted by today_pulse is not user-facing. See F83 spec.
12Timezone mismatch: today_pulse uses server-local date, no per-user timezone adjustment⚠️ gapviews.py:132 today = date.today(); user ahead of server timezone sees yesterday’s data at their local midnight
13Visual QC: screenshot flow captures the actual /intel/pulse page, not Today fallback content✅ match2026-06-18 screenshot flow navigates directly to /intel/pulse; 05-daily-pulse/01-pulse-page.png shows the F07 form/history page. Vietnamese title localized in vi/pulse.json.

Summary: 11/13 claims verified; 2 gaps flagged (date validation, timezone). The two previously-recorded spec drifts are now closed in the contract text: same-day resubmission overwrite behavior is documented in §7, and today_pulse’s 200 not_submitted empty state is documented in §5 and §9.

12. Open Questions

  • Per-org custom questions: addressed in F83 — Daily Pulse v2, which introduces role-aware hint chips, industry-aware extraction, and OrgPulseConfiguration for company-level overrides.

13. Bug / Change Log

2026-07-13 — Contract Graph migration and pulse writer boundary

Type: Contract and authorization traceability

Summary:

  • Added the canonical F07 CUE contract for today, submit, list, retrieve, and process, with 10 executable proof records covering retention on extraction failure, evidence, tenant isolation, and role boundaries.
  • Contract proof collection found that submit_pulse and PulseViewSet.process applied only IsOrgMember, allowing viewers to submit and process pulses despite the member-write/viewer-read contract. Added IsPulseWriter to both paths and regression proofs.
  • Evidence-source CRUD, adherence, F83 configuration, follow-up, and F103 rollup routes remain explicitly outside the core F07 contract.

Impact: Viewer pulse access is now read-only; active non-viewer members retain submit/process access.

Tests: The Contract Graph verifier collects and passes all 10 F07 pytest proof records. F07 remains partial because client-date validation, timezone semantics, and browser/native runtime gaps are explicit in contracts/features/F07.cue.

2026-07-10 — PulseReceipt fingerprint + 409 enforcement + concurrent race test + dead-letter UI + clearScope hooks (issue #1647 r03)

Type: Feature + Bug Fix

Summary:

  • PulseReceipt now stores request_fingerprint (SHA-256 of {date, answers}) and links to PulseEntry. Same-key/different-payload returns 409 Conflict. Added concurrent race test (PostgreSQL) and interrupted-first-attempt test. Updated backend DIAGRAMS.md.
  • KMP: wired draftStore + clearScope on Android/iOS. Added DeadLetterEntry UI with en/vi retry/discard strings. Added clearPulseStore() to Android SharedKmpViewModel and iOS APIClient.logout()/switchOrganization().

2026-07-02 — Correct stale test-file path reference (batch-1 spec audit)

Type: Documentation Fix

Summary:

  • §7 row 6: annotated frontend/src/test/usePulseAdherenceShare.test.tsx as absent — the hook test file was described in the 2026-06-22 change log entry but was never shipped to the repo (find frontend/src -name usePulseAdherenceShare.test.tsx returns nothing). The builder test at frontend/src/test/pulseAdherenceShare.test.ts (row 5) does exist. The 2026-06-22 change log entry is preserved as-is (historical record); the live §7 row now reflects the actual test surface.

2026-07-02 — Close stale pulse spec-drift notes

Type: Documentation Change

Summary:

  • Updated §7 row 1 to match shipped same-day resubmission behavior: repeat submit overwrites the same-day entry and returns 200.
  • Closed §11 audit rows 8 and 9 from ⚠️ spec drift to ✅ match because the contract text now matches shipped behavior.
  • Tightened the §11 summary so it reports only the two real remaining gaps: date validation and timezone handling.

Reason:

  • The spec had already been partially corrected in §5 and §9, but §7 and the §11 summary still recorded old contradictions as live drift.

Impact:

  • Documentation only. No code, API, or test behavior changed.

2026-07-02 — Fix stale evidence citations and i18n gap clarification (batch-2 spec audit)

Type: Documentation Fix

Summary:

  • §11 row 5: corrected stale evidence reference — there is no _compute_confidence function in apps/ai/services.py; confidence is assigned inline at ~922 (commitment), ~965 (risk), ~1008 (decision) within the keyword extraction loops.
  • §11 row 11: expanded the F83 “fixed” note to clarify that today_pulse still emits DEFAULT_PULSE_QUESTIONS (Vietnamese) as a legacy field, but this is NOT a real i18n bug for international users — usePulseToday() is exported from usePulses.ts but never imported by any component; PulsePage reads usePulseProfile() for hint chips and usePulsesList() for history. The .questions field from the today_pulse response is fully non-user-facing.
  • §11 row 12: fixed stale code reference — views.py:132 calls date.today(), not timezone.now().date() at line 23; the per-user-timezone gap remains real.
  • §7 row 6: expanded the “file absent” note to name the partial coverage source (TeamCheckInsCard.test.tsx) and the builder test (pulseAdherenceShare.test.ts).

2026-06-22 — Team check-ins share affordance (third distribution object)

Type: Feature (growth — mirrored from F12 Brief share)

Summary:

  • Added a leader-only Share button to the TeamCheckInsCard on /intel/pulse. Tapping it turns the day’s adherence into a chat-pasteable artifact ({org} · Lumi Pulse · {date} header, ✅ submitted/total line, plural-aware missing-names list, F59 attributed referral footer) and routes through native navigator.share with a clipboard fallback. The hook is gated by the same leader check that already gates the card, so non-leaders never see the affordance.
  • Files: frontend/src/lib/pulseAdherenceShare.ts (pure builder), frontend/src/hooks/usePulseAdherenceShare.ts (orchestration hook — mirrors useBriefShare), frontend/src/components/pulse/TeamCheckInsCard.tsx (button + wiring), plus i18n keys in pulse namespace propagated across all 62 locales (en + vi hand-translated; others fall back to English via scripts/propagate-new-i18n-keys.mjs until the i18n-translate fleet runs).

Reason:

Tests:

  • frontend/src/test/pulseAdherenceShare.test.ts (10 cases — builder shape, org-stamp present/absent/whitespace, all-clear, missing-names, plural singular vs plural, referral vs branded footer).
  • frontend/src/test/usePulseAdherenceShare.test.tsx (5 cases — native success, AbortError, clipboard fallback, clipboard error, builder receives referral + org name).
  • frontend/src/test/TeamCheckInsCard.test.tsx (extended — Share button presence/absence + invokes hook on click).

Impact: Additive UI on a leader-gated card. No model/API change. No new endpoint. No deploy migration. The leader already sees the data on screen; the share button is purely an editorial-discretion forward — same pattern as Brief share.

2026-07-10 — Pulse idempotency key + platform-persistent offline queue (issue #1647)

Type: Feature + Bug Fix

Summary:

  • Added idempotency_key (UUIDField, nullable) to PulseEntry model. On submit, the backend checks for an existing pulse with the same (org, user, key) — if found, returns the existing response without re-extraction. This prevents duplicate downstream Commitment/Risk/DecisionRequest items when the client retries the same logical submission (network timeout → reconnect → same key).
  • Added idempotency_key field to PulseSubmitSerializer (optional, nullable — backward compatible with web forms).
  • Updated KMP PulseSubmitRequest DTO with idempotencyKey: String?.
  • Updated PulseScreenModel.submit() to generate a UUID idempotency key per submission and persist it in the offline queue.
  • Updated PulseScreenModel.flushQueue() to pass the stored idempotency key on retry.
  • Wired persistent JsonPulseSubmitQueue and JsonPulseProfileCache on Android (via LumiPrefs.getString/putString/removeString → EncryptedSharedPreferences wrapper) and iOS (via NSUserDefaults-backed StringStore adapter). These replace the previous in-memory defaults, so offline queued pulses and the resolved profile cache survive process death on both platforms.

Impact:

  • Backend: new migration (0009), 4 new tests, coverage ≥97%.
  • Shared KMP: 3 new tests in PulseScreenModelTest (34→37), all pass.
  • Android: persistent offline queue wires JsonPulseSubmitQueue(prefsStringStore) + JsonPulseProfileCache(prefsStringStore).
  • iOS: persistent offline queue wires JsonPulseSubmitQueue(iosStore) + JsonPulseProfileCache(iosStore) via NSUserDefaults.
  • Doc updates: F07 spec §5/§7/§13, backend/DIAGRAMS.md §5 (idempotency guard).
  • Never claims physical packet exactly-once — network delivery can retry; only the tenant-scoped logical submission is exactly-once.

Summary:

  • The screenshot gallery’s Daily Pulse section captured the Today dashboard instead of the F07 pulse page.
  • This made the visual artifact misleading: it did not verify the six-question form, submitted read-back, or pulse history.
  • Fixed the screenshot flow to navigate directly to /intel/pulse and capture the form/read-back state under frontend/screenshots/05-daily-pulse/.
  • Localized the Vietnamese page title from Daily Pulse to Báo cáo hằng ngày, so the page chrome matches the forced-Vietnamese screenshot flow.
  • Localized the Vietnamese sidebar label for the same route, removing the mixed-language first viewport.
  • Aligned the frontend pulse question labels and sample answers with DEFAULT_PULSE_QUESTIONS so q3/q4/q5/q6 feed the expected commitment, decision, risk, and tomorrow-priority slots.

Verification:

  • frontend/screenshots/05-daily-pulse/01-pulse-page.png shows the actual Pulse UI.
  • pnpm exec playwright test tests/e2e/screenshot-flow.spec.ts --project=chromium --reporter=list -g "05-daily-pulse"

2026-06-15 — Frontend route namespaced

Frontend route namespaced: /pulse -> /intel/pulse (app-wide IA restructure; API paths unchanged).

2026-06-14 — Strategy v2 realignment (positioning)

Type: Documentation Change

Summary:

  • Reframed the §2 “Competitor job” lines (×3) from Asana/Monday/Notion check-ins to what the pulse actually replaces in the first market: scattered Zalo/WhatsApp messages, an Excel “daily report” tab, and owner memory (with trade voice: “anh ơi xong chưa?”, thi công). Acceptance-criteria [x] lines left untouched.
  • §3: rewrote “Competitive note — Asana/Monday/Notion check-ins” → “Competitive note — the Excel ‘daily report’ tab and owner memory” (the chat side is already covered by the existing Zalo note); tightened the Zalo note to name the real competitor explicitly (Zalo/WhatsApp + Excel + owner memory).
  • Added a “Relation to the F70 wedge” note: the pulse is the steady-state capture habit that the F70 cold-start wedge (paste→brief) graduates into once staff are onboarded (Loop 1).

Reason:

  • Realign to competitive-strategy-v2.md (Inversion 7 — real competitor is Zalo/WhatsApp + Excel + owner memory; §7.2 staff updates/pulse as low-friction capture; §10.4 Loop 1) and the registry “Positioning doctrine (v2)” rules 1, 4, and 5. F07 is the natural home to cross-link the F70 wedge (which the registry §1 names as the cold-start the steady-state pulse depends on).

Impact: Positioning only. No behavior change — the six fixed questions, submit/extraction pipeline, EvidenceSource creation, and per-day uniqueness are all unchanged.

Tests: None (documentation change).

Notes: No edits to §4 Data Model, §5 API Contract, §7 Test Plan, §9 Error States, §11 Implementation Audit, the six-question contract, or the Status line.

2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)

Type: Documentation Change

Summary:

  • Numbered “Current Behavior” as §8; §8 Diagrams → §10; §10 Edge Cases absorbed into §11; §11 Error States → §9 (table); §9 Implementation Audit → §11 (12-row table); §12 Open Questions stays §12; §13 Bug/Change Log stays §13.
  • §9 Error States converted from narrative to table with code references.
  • §11 audit: 12 rows, 9 ✅, 3 gaps + 2 benign spec drifts documented.
  • §5 API contract: today_pulse note corrected to “200 not_submitted if none — not 404”.

2026-06-14 — Document pulse question localisation gap (international markets)

Type: Documentation Change

Summary:

  • Added to §9 Implementation Audit: DEFAULT_PULSE_QUESTIONS are hardcoded Vietnamese strings. Users in international target markets (MY, ID, KSA, PH, LV) see Vietnamese questions. Changing question text changes the AI extraction contract — requires dedicated rework spec before implementation. Cross-referenced to F69 §10 Open Question 3.

Impact: Spec-only. No code changes. Surfaces a known gap before international market launch.

2026-06-10 — Submit auto-triggers extraction; /process/ becomes retry-only

Type: Behavior Change

Summary:

  • POST /api/pulses/submit/ now calls extract_pulse_entry(pulse.id, evidence.id) synchronously after creating the EvidenceSource. Success → status="processed"; exception → status="failed" (logged via logger.exception), submission still returns 201.
  • POST /api/pulses/<id>/process/ is now idempotent: re-processing an already-processed pulse returns 200 with no duplicate items; processing a failed pulse retries extraction.
  • PulsePage.tsx shows a retry button (pulsesApi.process(id)) when status="failed".

Reason:

  • A two-step submit-then-process flow forced employees/CEOs to orchestrate the AI pipeline manually — the single most important UX fix in the pipeline per §9.

Impact:

  • Extraction now happens transparently on submit. failed pulses are recoverable via retry without re-typing answers.

2026-06-12 — Add Edge Cases and Error States sections

Type: Documentation Change

Summary:

  • Added §10 Edge Cases (E1–E11) and §11 Error States (ES1–ES8). Renumbered Open Questions → §12, Bug/Change Log → §13.

2026-06-12 — Success metrics + competitive positioning (G-r18)

Type: Documentation Change

Summary:

  • Added §1b Success Metrics with four tracked signals (completion rate, time-to-complete, extraction yield, weekly stickiness).
  • Added competitive framing to §3 — names WhatsApp/Zalo groups as the concretely worse alternative.

2026-06-12 — JTBD stories + extraction precision + Asana/Monday/Notion differentiation (G-r23)

Type: Documentation Change

Summary:

  • Added extraction precision metric to §1b.
  • Rewrote §2 User Stories as CEO/member jobs-to-be-done with named competitor comparison per story.
  • Expanded §3 competitive framing to name Asana/Monday/Notion check-in approaches directly.

14. Success Metrics (Appendix)

  • Pulse completion rate > 70% within 2 hours of daily prompt: measured as the share of active members who submit a pulse on a given day within 2 hours of the daily prompt (Telegram or channel notification). Tied to §1’s claim that “if completing it takes more than ~2 minutes or feels like surveillance, people stop” — completion rate is the direct signal of whether the pulse habit is sustainable.
  • Median time-to-complete ≤ 120s (P95 ≤ 180s): measured client-side from first field interaction to submit response. Tied to §1’s “~2 minutes” benchmark and §2 story 1 (“up to 6 plain questions, all optional”) — if a warehouse staffer takes longer than 3 minutes, the form is too heavy.
  • Signal extraction yield ≥ 0.4 items per submitted pulse: tracked as extracted Commitment + Risk + DecisionRequest objects created within 5 minutes of a pulse’s submission, divided by total pulse submissions in the same period. Tied to §2 story 1 (“Lumi extracts commitments, risks, and decisions from six plain questions”) — low yield means pulses are being written but the Brief is not being fed. Computable from existing models (PulseEntry count vs. downstream item counts); no new instrumentation.
  • Extraction precision > 75%: measured as the share of extracted items (Commitments + Risks + DecisionRequests) that remain open, monitoring, or resolved (not cancelled, not dismissed) 7 days after creation. This is the quality counterpart to extraction yield (which measures quantity). Tied to §2 story 1’s competitor distinction (“Asana/Monday produce raw text the CEO must mentally filter; Lumi extracts structured signals”) — if precision drops below 75%, the CEO loses trust and reverts to reading raw pulse text, defeating the extraction wedge. Computable from existing model status changes and created_at; no new instrumentation.
  • Weekly habit stickiness > 60%: measured as the share of active members who submit a pulse on ≥ 4 of their org’s working days per week. Tied to §2 story 3 (“who pulsed today and who never does”) — if fewer than 60% of members sustain the pulse habit, the Brief permanently starves of new signals.