Spec: F83 — Daily Pulse v2 (Role-aware & Industry-aware Adaptive Pulse)
Status: shipped Supersedes: F07 (Daily Pulse) — F07 remains the baseline; this spec upgrades the prompt layer and extraction contract on top of it.
Contract Graph note (2026-07-16): F83 is represented by
contracts/features/F83.cuewith executable backend/Vitest proof and explicit browser, native-runtime, and live-provider gaps. Missing pack translations fall back to Vietnamese, matching the shipped loader, resolver, and tests.
1. Problem & CEO Job
Serves: see clearly · trust the signal
F07 hardcodes the same six Vietnamese questions for every member, regardless of role, industry, or daily output.
This creates two failure modes:
- Noise: “Bạn đã hứa gì với khách?” can produce a useful commitment from a field technician but noise from a finance officer; the pipeline does not know who is speaking.
- Missing signal: “Which jobs are unassigned tomorrow?” never appears for a dispatcher, nor “Which invoices are overdue today?” for collections. The pulse is too generic to surface either.
Even with high completion, the Brief receives vague commitments instead of each role’s risks and operational knowledge.
The fix. Daily Pulse v2 introduces three layers of personalization:
- Layer 1 — Core (universal): Three unchanged free-text questions.
- Layer 2 — Role pack: 3–5 catalog-backed hint chips under the textarea: jobs, materials, and return visits for a field technician; invoices, overdue amounts, and approvals for finance.
- Layer 3 — Industry signals: Industry-specific definitions and vocabularies let the pipeline
read “board mạch chưa về” as field-service
missing_parts, not generic text.
Key design principle: Role determines what hints are shown. Industry determines how Lumi interprets the answer. The user still writes in one free-form box.
2. User Stories & Acceptance Criteria
2.1 Field member — role-appropriate hints
- As a field technician, I want the pulse input to show hints that match my actual daily work (jobs done, parts missing, return visits, customer commitments), so that I remember to report the signals my manager actually needs.
- Given I am a member with role
field_technicianin an org with industryfield_service_technical_maintenance, when I open/intel/pulse, the textarea placeholder and hint chips reference jobs, parts, and site visits — not financial approvals or pipeline deals. - Given I submit free text that mentions a missing part, when extracted, the result is a risk item with signal type
missing_partsand appropriate confidence (0.65–0.75 keyword, 0.85–0.90 LLM). - Given I am a new user with no role set, when I open the pulse, I see only core prompts (Layer 1 fallback) with no role chips.
- Given I am a member with role
2.2 CEO / Owner — receiving, not filling
- As a CEO, I want to fill in only what changed strategically (key decisions, new commitments, priority shifts), so that I am not treated as a field reporter while still being a source for the Brief.
- Given I have role
ceo_owner, the pulse form shows at most 3 chips (strategic change, major customer risk, executive commitment) and does not ask about jobs, parts, or daily operational items. - Given I submit a pulse as CEO, the extracted items are tagged with high weight for the Brief.
- Given I have role
2.3 Org admin — configure industry and role overrides
- As an org admin, I want to set my company’s industry and customize which questions are surfaced per role, so that the pulse fits our specific operations without Lumi guessing.
- Given I am an admin, I can set
industry_idfor the org viaPATCH /api/pulses/config/. - Given the org has
industry_id = "field_service_technical_maintenance", all member pulses resolve using that industry’s signal catalog. - Given I add a custom question text to a role’s pack, that text appears as a chip for all members of that role in my org.
- Given I disable a question ID, it is removed from the role pack for all members.
- Given I set a materiality threshold (e.g., 10,000,000 VND), the extraction pipeline uses that threshold when classifying financial signals.
- Given I am an admin, I can set
2.4 Adaptive follow-up — selective, not interrogative
- As a team member, I want Lumi to ask at most one or two clarifying questions when my update is genuinely ambiguous, so that I am not subjected to a questionnaire after every submission.
- Given I submit “job Quận 7 chưa xong”, the keyword
"chưa xong"matches_FOLLOW_UP_RULES→"jobs_unfinished", and the response includes exactly one follow-up question. (As-built: actualcopy_vifrom the pack is “Công việc nào chưa xong và vì sao?” — the earlier text “Job này dự kiến trễ bao lâu…” was a spec-draft placeholder that was never in the pack.) - Given I submit a complete update with all critical fields present, no follow-up is sent.
- Given follow-up was already asked for the same gap within 2 days, it is not asked again.
- Maximum 2 follow-up questions per submission, regardless of missing fields.
- Given I submit “job Quận 7 chưa xong”, the keyword
2.5 Multi-locale pulse
- As a team member in Malaysia or Saudi Arabia, I want the pulse form to appear in my configured locale, so that I can report in the language I work in.
- Given org locale is
en, hints and placeholder appear in English. - Given org locale is
vi, hints and placeholder appear in Vietnamese. - Given the pack has no translation for a locale, it falls back to Vietnamese. (As-built: fallback is
copy_vi/name_vithroughoutpack_loader.pyandprofile_resolver.py— not English. The pack was authored Vietnamese-first; English was planned as the international fallback but Vi is what the code uses.)
- Given org locale is
2.6 Company library — custom roles and question packs
- As an org admin, I want to build a custom question library for my company’s specific roles and terminology, so that Lumi fits our operations without relying on generic industry templates.
- Given I create a custom role
"senior_fitter"with a prompt pack of 5 questions using our internal vocabulary, members assigned that role see those questions as chips. (As-built: fully shipped end-to-end. Admin creates the role in the Pulse Config page → Custom roles section (CustomRoleForm.tsx,POST /api/pulses/config/roles/);resolve_profile()turns itsprompt_idsinto chips — seetest_pulse_v2_p2.py::TestProfileResolver::test_custom_role_pack_resolves+test_custom_role_with_base_role_inherits_prompts, and frontendsrc/test/CustomRoleForm.test.tsx.) - Given the company library has a custom signal
"missing_bom", the extraction pipeline treats it as amissing_partsvariant with my company’s label. (As-built:resolve_profile()Step 6 unions every enabled custom question’smaps_to_signal_idintoextraction_context.signal_ids(order-preserving, deduped, disabled questions skipped).extract_pulse_entryreadssignal_idsfrom the profile snapshot and maps each through_SIGNAL_KEYWORD_MAP/FIELD_SERVICE_MAP, so a custom question mapping to a canonical built-in signal (e.g.missing_parts) activates that signal’s extraction keywords for the org. Tests:test_pulse_v2_p2.py::TestProfileResolver::test_custom_question_signal_in_extraction_context(+ disabled / empty-signal cases).)
- Given I create a custom role
3. Scope Boundaries
In:
- Profile resolution API (core + role pack + industry config + org overrides → resolved profile)
daily_update_packcontent loader (backend reads JSON files fromdaily_update_pack/at repo root — no copy step; see §11.1)- API endpoints:
GET /api/pulses/profile/,GET /api/pulses/pack-version/,GET/PATCH /api/pulses/config/,GET/POST /api/pulses/config/roles/,GET/PATCH/DELETE /api/pulses/config/roles/<role_id>/,GET/POST /api/pulses/config/questions/,GET/PATCH/DELETE /api/pulses/config/questions/<question_id>/,GET/PATCH /api/pulses/preferences/me/ - DB models:
OrgPulseConfiguration,OrgCustomRolePack,OrgCustomQuestionLibrary,UserPulsePreference - Extension of
PulseEntry: addsprofile_snapshot,follow_up_qa,resolved_localefields - Extension of extraction pipeline: signal-catalog-driven extraction using resolved profile
- UI: single textarea with dynamic hint chips; industry-specific placeholder text
- Caching: pack version hash on backend (Redis); ETag on API responses; web client uses TanStack Query 1-hour in-memory stale window (no localStorage layer); mobile/desktop native cache deferred
- i18n: follow-up keys added to existing
pulsenamespace; config page keys in newpulse_confignamespace. (As-built:pulse_v2namespace was planned but not created — new keys were added topulsenamespace to avoid creating a new i18n bundle.)
Out:
- The actual Brief rendering (F12 owns it; this spec only improves what the pulse feeds into it)
- Pulse reminders / notifications (Telegram prompt is F21; WhatsApp is F76)
- Voice input (future; UI layer only, no spec change needed)
- AI-powered industry auto-detection from org name / description (Phase 2; not in this spec)
- Custom signal severity ML model (Phase 2)
- Per-user pulse analytics dashboard (F07 covers adherence card; deeper analytics are separate)
4. Data Model
4.1 daily_update_pack content — not in DB
The content (question catalog, signal catalog, role packs, industry configs) lives as JSON files bundled with the backend, not as DB rows. The source of truth is the files under daily_update_pack/ at repo root; the backend reads them directly from there — no copy step (see §11.1). The backend loads them into memory at startup (or from Redis cache). This makes the content versionable via git, auditable, and deployable without migrations.
daily_update_pack/ ← at monorepo root (read directly by backend)
├── manifest.json # version + counts
├── config/
│ ├── core_prompts.json # 3 universal prompts
│ ├── question_catalog.json # reusable question definitions
│ ├── signal_catalog.json # signal definitions per industry
│ ├── roles.json # role → prompt pack mappings
│ ├── industries.json # industry → signals/entities/KPIs
│ ├── resolution_rules.json # merge/selection/follow-up rules
│ └── combined_config.json # pre-merged snapshot (build artifact)
├── examples/ # reference resolved profiles (test fixtures)
└── schemas/ # JSON Schema files for config validation
Version: manifest.json carries "version": "1.0.0". The sha256 is NOT in manifest.json — it is computed at runtime by PulsePackLoader._compute_sha256(pack_dir) by concatenating the bytes of the 7 _REQUIRED_FILES (manifest + 6 config files) in sorted order, then taking the first 16 hex characters of the sha256 digest. Stored in PulsePack.sha256. Clients never see the pack sha256 directly — they use the ETag returned by GET /api/pulses/profile/ (see §5.1).
4.2 New models
# apps/pulses/models.py additions
class OrgPulseConfiguration(UUIDModel):
"""Company-level pulse configuration. One per org."""
organization = OneToOneField(Organization, on_delete=CASCADE, related_name='pulse_config')
industry_id = CharField(max_length=80, blank=True) # e.g. "field_service_technical_maintenance"
pack_version = CharField(max_length=40, default='') # last resolved pack version
disabled_question_ids = JSONField(default=list) # question IDs to suppress globally
additional_question_ids = JSONField(default=list) # org-wide extra questions
role_pack_overrides = JSONField(default=dict) # {role_id: {add: [...], remove: [...]}}
signal_severity_overrides = JSONField(default=dict) # {signal_id: "critical"|"high"|"medium"}
materiality_threshold_vnd = PositiveBigIntegerField(null=True)
executive_brief_cutoff = TimeField(null=True) # e.g. 07:30
custom_company_vocab = JSONField(default=dict) # {local_term: canonical_entity}
created_at = DateTimeField(auto_now_add=True)
updated_at = DateTimeField(auto_now=True)
class Meta:
verbose_name = 'Org Pulse Configuration'
class OrgCustomRolePack(UUIDModel):
"""Custom role definitions beyond the built-in 28 roles."""
organization = ForeignKey(Organization, on_delete=CASCADE, related_name='custom_role_packs')
role_id = CharField(max_length=80) # e.g. "senior_fitter"
name_vi = CharField(max_length=120)
name_en = CharField(max_length=120)
base_role_id = CharField(max_length=80, blank=True) # extends built-in role
prompt_ids = JSONField(default=list) # ordered list from question catalog
summary_focus = JSONField(default=list)
max_suggested_prompts = PositiveSmallIntegerField(default=5)
class Meta:
unique_together = [('organization', 'role_id')]
class OrgCustomQuestionLibrary(UUIDModel):
"""Company-specific question entries that extend the shared question_catalog."""
organization = ForeignKey(Organization, on_delete=CASCADE, related_name='custom_questions')
question_id = CharField(max_length=80) # e.g. "org_missing_bom"
copy_vi = TextField()
copy_en = TextField()
hint_chip_vi = CharField(max_length=60, blank=True)
hint_chip_en = CharField(max_length=60, blank=True)
maps_to_signal_id = CharField(max_length=80, blank=True) # canonical signal for extraction
enabled = BooleanField(default=True)
class Meta:
unique_together = [('organization', 'question_id')]
class UserPulsePreference(UUIDModel):
"""Per-user preferences that layer on top of org config."""
user = OneToOneField(settings.AUTH_USER_MODEL, on_delete=CASCADE,
related_name='pulse_preference')
organization = ForeignKey(Organization, on_delete=CASCADE)
pulse_role_id = CharField(max_length=80, blank=True, default='') # job role for chip resolution
locale = CharField(max_length=10, default='vi')
preferred_input_mode = CharField(max_length=20, default='text',
choices=[('text','Text'),('voice','Voice'),('mixed','Mixed')])
# Resolved profile cache (avoids re-computing on every page load)
cached_profile_json = JSONField(default=dict, blank=True)
cached_pack_version = CharField(max_length=40, default='', blank=True)
profile_resolved_at = DateTimeField(null=True, blank=True)
class Meta:
db_table = 'user_pulse_preferences'
# OneToOneField(user) enforces one row per user; no separate unique_together needed
4.3 PulseEntry field additions
# Additions to existing PulseEntry model
profile_snapshot = JSONField(default=dict) # resolved profile at submission time (audit trail)
follow_up_qa = JSONField(default=list) # [{question_id, answer, answered_at}]
resolved_locale = CharField(max_length=10, default='vi')
4.4 Status transitions (no change to existing PulseEntry status FSM)
draft → submitted → processed | failed — unchanged from F07.
5. API Contract
All endpoints scoped to request.tenant (organization from X-Organization-ID header). All new endpoints follow TenantQuerySetMixin pattern.
5.1 Profile resolution
| Method | Path | Min role | Notes |
|---|---|---|---|
GET | /api/pulses/profile/ | member | Returns resolved pulse profile for current user. Cached per user + org config version + pack version. |
GET | /api/pulses/pack-version/ | member | Returns {"version": "1.0.0", "sha256": "..."}. Clients use for ETag-style cache invalidation. |
GET /api/pulses/profile/ response schema:
{
"pack_version": "1.0.0",
"locale": "vi",
"role_id": "field_technician",
"role_name": "Kỹ thuật viên hiện trường",
"industry_id": "field_service_technical_maintenance",
"industry_name": "Dịch vụ kỹ thuật hiện trường",
"core_prompts": [
{"prompt_id": "what_changed", "copy": "Hôm nay có gì thay đổi hoặc đáng chú ý?", "purpose": "daily_change"}
],
"hint_chips": [
{"question_id": "jobs_completed", "chip_label": "Việc đã xong"},
{"question_id": "jobs_unfinished", "chip_label": "Đang bị kẹt"},
{"question_id": "materials_needed", "chip_label": "Cần vật tư"},
{"question_id": "customer_commitments", "chip_label": "Đã hứa với khách"}
],
"placeholder_text": "Ví dụ: Đã hoàn thành bảo trì tại Vincom. Job Quận 7 chưa xong vì thiếu board mạch...",
"max_visible_chips": 5,
"adaptive_follow_up": {"enabled": true, "max_questions": 2},
"extraction_context": {
"signal_ids": ["missing_parts", "repeat_failure", "sla_risk", "..."],
"entities": ["job", "customer", "site", "part", "..."],
"kpis": ["jobs_completed", "first_time_fix_rate", "..."],
"signal_severity_overrides": {},
"custom_vocab": {},
"materiality_threshold_vnd": null
}
}
HTTP caching: ETag = "{pack.sha256}-{org_config_updated_at_timestamp}-{user_pref_updated_at_timestamp}-{locale}". Returns 304 Not Modified when If-None-Match matches. (Not “sha256(pack_version + org_config_hash + user_role)” as originally planned — the ETag components and formula were refined during implementation.)
5.2 Org pulse configuration
| Method | Path | Min role | Notes |
|---|---|---|---|
GET | /api/pulses/config/ | admin | Retrieve org pulse config |
PATCH | /api/pulses/config/ | admin | Update config (industry, overrides, thresholds) |
GET | /api/pulses/config/roles/ | admin | List custom role packs |
POST | /api/pulses/config/roles/ | admin | Create custom role pack |
GET | /api/pulses/config/roles/<role_id>/ | admin | Retrieve custom role pack |
PATCH | /api/pulses/config/roles/<role_id>/ | admin | Update custom role pack |
DELETE | /api/pulses/config/roles/<role_id>/ | admin | Delete custom role pack |
GET | /api/pulses/config/questions/ | admin | List company question library |
POST | /api/pulses/config/questions/ | admin | Add custom question |
GET | /api/pulses/config/questions/<question_id>/ | admin | Retrieve custom question |
PATCH | /api/pulses/config/questions/<question_id>/ | admin | Update/toggle question |
DELETE | /api/pulses/config/questions/<question_id>/ | admin | Delete custom question |
GET | /api/pulses/preferences/me/ | member | Retrieve user’s pulse preferences |
PATCH | /api/pulses/preferences/me/ | member | Update user’s pulse preferences |
5.3 Modified pulse submission
POST /api/pulses/submit/ — behavior change: response now includes follow_up_questions:
{
"date": "2026-06-25",
"answers": ["Đã hoàn thành bảo trì tại Vincom. Job Quận 7 chưa xong vì thiếu board mạch..."]
}
Response adds "follow_up_questions": [{"question_id": "...", "question_text": "..."}] — at most 2 items, only when follow-up is warranted (keyword-based detection on raw_text). (Note: key is question_text, not question — matches _generate_follow_up_questions output and AdaptiveFollowUp.tsx interface.)
As-built note: The original design included follow_up_answers in this request body for merging into extraction. Implementation shifted to a separate idempotent endpoint instead (see §5.5 below). The follow_up_answers field is still accepted by the serializer for forwards-compatibility but is not processed by the view.
5.5 Follow-up answer storage (added in implementation)
POST /api/pulses/<pulse_id>/follow-up/ — idempotent follow-up answer storage.
| Method | Path | Min role | Notes |
|---|---|---|---|
POST | /api/pulses/<pulse_id>/follow-up/ | member | Store one follow-up answer. Idempotent per question_id — re-posting same ID is a no-op. |
Request body:
{"question_id": "clarify_owner", "answer": "Anh Minh phụ trách"}
Response: full PulseEntrySerializer payload (includes updated follow_up_qa list). 404 if pulse_id belongs to a different org (tenant isolation enforced via organization_id filter).
5.4 Pack content endpoints (admin / internal)
| Method | Path | Min role | Notes |
|---|---|---|---|
GET | /api/pulses/pack/roles/ | admin | All built-in role definitions |
GET | /api/pulses/pack/industries/ | admin | All built-in industry configs |
GET | /api/pulses/pack/questions/ | admin | Full question catalog |
GET | /api/pulses/pack/signals/ | admin | Full signal catalog |
These endpoints serve the configuration UI. Response is the JSON content from the pack files, scoped by locale.
6. UI / UX
6.1 Routes and components
- Route:
/intel/pulse—PulsePage.tsx(existing, extended) - New hooks / components:
usePulseProfile(locale)— TanStack Query hook for/api/pulses/profile/;staleTime: 1h; backed by server-side ETag. (Originally planned asPulseProfileProvider.tsxcontext — implemented as hook instead; simpler, no extra React context layer needed.)HintChips.tsx— chip grid below the textarea; click insertschip_label:at cursorAdaptiveFollowUp.tsx— inline post-submit follow-up; max 2 questions; skip/send per questionPulseConfigPage.tsx— admin-only settings page at/settings/pulse-config, reached from the Sidebar → Organization group (“Pulse Configuration”,Radioicon, admin/owner-gated)RolePackEditor.tsx— drag-to-reorder chip editor per roleCustomQuestionForm.tsx— add/edit a company-specific question entry
6.2 Pulse form — user experience
Default state (not yet submitted):
┌─────────────────────────────────────────────────────────┐
│ Hôm nay có gì thay đổi hoặc đáng chú ý? │
│ │
│ [textarea — large, auto-grow, mobile-first] │
│ │
│ placeholder: "Ví dụ: Đã hoàn thành bảo trì tại │
│ Vincom. Job Quận 7 chưa xong vì thiếu board mạch..." │
└─────────────────────────────────────────────────────────┘
[Việc đã xong] [Đang bị kẹt] [Đã hứa với khách] [Ưu tiên mai]
[Gửi]
- Hint chips are optional and always visible when the resolved profile returns any chips (
chips.length > 0). (As-built: original design called for collapsible chips that expand on tap; implementation keeps them always visible — simpler and immediately scannable.) - Tapping a chip does NOT navigate to a separate field. It inserts
chip_label:at the cursor in the textarea; the full question copy is available via the chip’stitletooltip attribute. - No mandatory questions. No form fields. No progress bars. The CEO in Hanoi should be able to file in 30 seconds on mobile.
After submission (follow-up warranted):
✓ Đã gửi — Lumi đang xử lý...
─── Lumi cần thêm 1 điểm ───
Job Quận 7 đang trễ: ai chịu trách nhiệm mua board mạch?
[Nhập câu trả lời ngắn gọn...] [Bỏ qua] [Gửi]
At most 2 follow-up blocks. After answering or skipping, the normal “already submitted” read-back is shown.
Already submitted (read-back):
Same as F07 — shows the submitted text with extracted items.
6.3 Pulse Config page (/settings/pulse-config) — admin only
As-built sections — all 5 of the original design now ship:
- Industry — shadcn
<Select>dropdown listing all built-in industries. Selecting setsorg_pulse_config.industry_id. - Role packs —
RolePackEditorper built-in role (fromGET /api/pulses/pack/roles/): shows current chips from the role pack and lets admins add/remove question IDs via override lists. - Custom roles —
CustomRoleTable(<DataTable>from F66): lists the org’s custom roles with name,role_id, base role, and prompt count. An inline dialog creates/edits a role —role_id(withorg_prefix guidance), VI/EN names, an optional base-role<Select>(inherits the base pack’s prompts), a prompt-pack checklist drawn from the question catalog, and a max-suggested-chips number. Backed byGET/POST/PATCH/DELETE /api/pulses/config/roles/. (§2.6 AC1) - Extraction settings — materiality threshold (VND), brief cutoff time (HH:MM), custom vocabulary key→value pairs (one per line:
local_term=canonical_entity). - Company question library —
CustomQuestionTable(<DataTable>from F66): lists custom questions the org has defined withquestion_id,copy_vi/copy_en, mapped signal, andenabledtoggle. Supports add/edit/delete via inline dialog.
6.4 Hint chip behavior
- Maximum 5 chips visible at once (global cap from
resolution_rules.json::maximum_visible_hint_chips). Rotation: chips that have surfaced signals with management action in the last 7 days are prioritized.(Deferred — §12 Step 7 not implemented; chips are returned in build order.)Chips not answered in the last 2 days are surfaced before recently-answered ones.(Deferred — same Step 7 rotation.)- On mobile: chips wrap to the next line (
flex flex-wrap gap-2). (As-built: original design called for horizontal scroll; implementation wraps — avoids hidden content and is more accessible.)
6.5 i18n
Namespace: pulse (existing) — extended with new keys.
New keys added to en/pulse.json and vi/pulse.json (as-built key names — original design used follow_up_* with underscores; implementation uses followup_* without the separator):
{
"hint_chips_label": "Gợi ý",
"no_role_set": "Chưa có vai trò — hãy yêu cầu quản trị viên thiết lập.",
"followup_header": "Lumi cần thêm một chi tiết",
"followup_placeholder": "Câu trả lời ngắn...",
"followup_send": "Gửi",
"followup_skip": "Bỏ qua",
"followup_submitted": "Đã lưu câu trả lời bổ sung",
"followup_error": "Không thể lưu câu trả lời bổ sung"
}
New pulse_config namespace for the settings page (keys: title, subtitle, admin_only, section_industry, industry_label, industry_none, industry_hint, section_roles, roles_hint, section_extraction, and others — see frontend/src/i18n/locales/en/pulse_config.json for the full list). The config_* keys shown in the original spec design draft belong to this namespace, not to pulse.
6.6 DESIGN.md and TASTE.md compliance
- Textarea:
bg-card,border-border,text-foregroundtokens only. No raw Tailwind palette. - Hint chips:
<Badge variant="outline">(shadcn) — not custom buttons. - Follow-up block: plain
<div>withborder-t border-[var(--color-border)]separator and Lumi design tokens (--color-civic-navy,--color-soft-slate,--color-lumi-blue). (As-built: original design called for a<Card>withborder-primary/40; implementation uses a div to keep it visually integrated with the submit card rather than adding a second card nesting level. No stock Tailwind palette classes — all tokens.) - Config page: uses
<DataTable>(F66) for question library table. - No decorative chrome. Mobile-first sizing.
7. Test Plan
7.1 Backend tests
Note: Location column uses the spec-planned class names. Actual implementation splits across test_pulse_v2_p1.py (pack loader/models), test_pulse_v2_p2.py (profile resolver/endpoints), test_pulse_v2_p3.py (org config API/extraction), test_pulse_v2_p4.py (pack content endpoints, profile_snapshot, follow-up), and test_pulse_v2_p5.py (industries/roles pack endpoints) under different class names. See §13 Implementation Audit for the accurate file-by-file mapping.
| # | Case | Type | Actual test (file::Class::method) |
|---|---|---|---|
| 1 | GET /api/pulses/profile/ returns correct role pack for field_technician in HVAC org | backend | test_pulse_v2_p2.py::TestProfileResolver::test_field_technician_hvac_has_job_chips + TestPulseProfileEndpoint::test_with_role_returns_chips |
| 2 | CEO role returns at most 3 chips; no operational job questions | backend | test_pulse_v2_p2.py::TestProfileResolver::test_ceo_role_has_at_most_3_chips; loader: test_pulse_v2_p1.py::TestPulsePackLoader::test_ceo_role_max_suggested_prompts |
| 3 | User with no role set falls back to core prompts only; no chips | backend | test_pulse_v2_p2.py::TestProfileResolver::test_no_role_fallback_zero_chips + TestPulseProfileEndpoint::test_no_role_returns_profile_with_zero_chips |
| 4 | Org with no industry set falls back to generic signals | backend | test_pulse_v2_p2.py::TestProfileResolver::test_no_industry_uses_core_signals |
| 5 | Disabled question IDs are absent from resolved profile | backend | test_pulse_v2_p2.py::TestProfileResolver::test_disabled_question_ids_excluded |
| 6 | Additional question IDs are present in resolved profile | backend | test_pulse_v2_p2.py::TestProfileResolver::test_additional_question_ids_included |
| 7 | Custom role pack resolves correctly | backend | test_pulse_v2_p2.py::TestProfileResolver::test_custom_role_pack_resolves |
| 8 | Company vocabulary maps custom term to canonical entity in extraction context | backend | test_pulse_v2_p2.py::TestProfileResolver::test_custom_vocab_in_extraction_context |
| 8b | Enabled custom question’s maps_to_signal_id is unioned into extraction_context.signal_ids; disabled / empty signals excluded; no duplicates (§2.6 AC2) | backend | test_pulse_v2_p2.py::TestProfileResolver::test_custom_question_signal_in_extraction_context + test_disabled_custom_question_signal_not_in_extraction_context + test_empty_maps_to_signal_id_not_added |
| 8c | Custom role with base_role_id inherits base pack prompts (deduped); per-role override remove drops a base question; unknown additional ID dropped silently (§12 Steps 2–4) | backend | test_pulse_v2_p2.py::TestProfileResolver::test_custom_role_with_base_role_inherits_prompts + test_role_pack_override_remove_excludes_question + test_additional_ids_dedup_and_unknown_id_dropped |
| 9 | GET /api/pulses/profile/ returns ETag; second request with matching If-None-Match returns 304 | backend | test_pulse_v2_p2.py::TestPulseProfileEndpoint::test_returns_etag_header + test_304_on_matching_etag |
| 10 | ETag invalidated when org config changes | backend | test_pulse_v2_p2.py::TestPulseProfileEndpoint::test_etag_different_after_org_config_change |
| 11 | follow_up_answers merges into extraction contextfollow_up_answers accepted by PulseSubmitSerializer but not read in submit_pulse view; design shifted to a separate idempotent endpoint. Follow-up storage covered by test_pulse_v2_p4.py::TestPulseFollowUp::test_follow_up_answer_stored. | backend | test_pulse_v2_p4.py::TestPulseFollowUp::test_follow_up_answer_stored |
| 12 | Follow-up: at most 2 questions returned per submit; same question not repeated if asked within 2 days | backend | test_pulse_v2_p4.py::TestFollowUpQuestionGeneration::test_follow_up_questions_at_most_two + test_question_not_repeated_if_asked_within_2_days |
| 13 | Cross-tenant: profile endpoint returns correct org’s config, not another org’s | backend | test_pulse_v2_p2.py::TestPulseProfileEndpoint::test_tenant_isolation_different_org |
| 14 | Cross-tenant: custom role/question not visible to other orgs | backend | test_pulse_v2_p3.py::TestOrgPulseConfig::test_tenant_isolation; custom roles/questions: TestOrgCustomRolePacks + TestOrgCustomQuestions (various) |
| 15 | PATCH org pulse config — non-admin returns 403 | backend | test_pulse_v2_p3.py::TestOrgPulseConfig::test_patch_member_forbidden |
| 16 | Signal extraction uses industry signals when present | backend | test_pulse_v2_p3.py::TestExtractionSignalEnrichment::test_industry_keyword_triggers_risk |
| 17 | Signal extraction falls back to core signals when no industry | backend | test_pulse_v2_p3.py::TestExtractionSignalEnrichment::test_no_industry_context_uses_base_patterns |
| 18 | Materiality threshold: amounts below threshold not flagged as financial risk | backend | test_field_service_ontology.py::TestMateriality::test_sub_threshold_deposit_not_flagged + test_above_threshold_deposit_flagged |
| 19 | PulseEntry.profile_snapshot is populated on submit | backend | test_pulse_v2_p4.py::TestProfileSnapshotOnSubmit::test_profile_snapshot_stored_on_submit |
| 20 | Locale: GET /api/pulses/profile/?locale=en returns English copy | backend | test_pulse_v2_p2.py::TestProfileResolver::test_english_locale_returns_en_copy + TestPulseProfileEndpoint::test_locale_param_changes_copy |
7.2 E2E tests
Note: Cases #4 and #5 below were the original plan. Actual implementation split them differently — see as-built column. Case #5 (disable-question flow) requires cross-session state changes impractical in the seed fixture and was deferred. Two additional cases (#8, #9) were added during implementation to cover pulse-config page load independently of the save flow.
Verification (2026-06-25, p23): the full 9-case spec was run live on chromium (USE_SQLITE=1 auto-seed) — 9 passed. The run surfaced and fixed a real regression: case #8 (industry selector) had silently broken when the IndustrySelector migrated to shadcn <Select> (p7) — the <label> lost its association, so getByLabel(/industry/i) found nothing. Fixed by adding htmlFor/id + aria-label (a genuine a11y fix). Case #2’s h1 assertion is mildly cold-start-flaky locally (server still seeding on the very first test); it passes on warm runs and CI uses retries: 2.
| # | Case | As-built | Location |
|---|---|---|---|
| 1 | Field technician: hint chips visible, clicking chip inserts text in textarea | ✅ | e2e/daily-pulse-v2.spec.ts::describe("Role hints") |
| 2 | Submit triggers follow-up question block when owner missing | ✅ | e2e/daily-pulse-v2.spec.ts::describe("Adaptive follow-up") |
| 3 | Skip follow-up → success state remains, no error shown | ✅ | e2e/daily-pulse-v2.spec.ts::describe("Adaptive follow-up") |
| 4 | Admin navigates to /settings/pulse-config, page loads without error | ✅ (planned: “sets industry, saves” — actual test asserts page load + no TypeError/404) | e2e/daily-pulse-v2.spec.ts::describe("Pulse config") |
| 5 | — | — | |
| 6 | CEO role: no job/parts chips visible | ✅ | e2e/daily-pulse-v2.spec.ts::describe("Role hints") |
| 7 | User with no role: no chips, only generic placeholder | ✅ | e2e/daily-pulse-v2.spec.ts::describe("Role hints") |
| 8 | Admin can see industry selector on pulse config page | ✅ (fixed p23: label htmlFor/id association restored after the p7 shadcn migration broke getByLabel) | e2e/daily-pulse-v2.spec.ts::describe("Pulse config") |
| 9 | Admin can see built-in roles list section on pulse config page | ✅ (added in implementation) | e2e/daily-pulse-v2.spec.ts::describe("Pulse config") |
| 10 | Admin can open the custom-role create dialog on pulse config page | ✅ (added with custom-roles UI) | e2e/daily-pulse-v2.spec.ts::describe("Pulse config") |
7.3 Frontend unit tests
| # | Case | Location |
|---|---|---|
| 1 | resolveProfile() merges core + role + industry + overridestest_pulse_v2*.py::TestProfileResolution. No frontend equivalent exists. | N/A |
| 2 | HintChips renders correct chips; click fires onChipSelect (7 cases) | src/test/HintChips.test.tsx ✅ |
| 3 | AdaptiveFollowUp shows at most 2 questions; skip hides component (11 cases) | src/test/AdaptiveFollowUp.test.tsx ✅ |
| 4 | PulseProfileProvider cachingusePulseProfile TanStack Query hook instead. Hook behavior (profile renders chips / no-role fallback / locale placeholder) covered by 4 existing cases in src/test/PulsePage.test.tsx. Pack-version localStorage logic (§11.4) was not built — ETag + TanStack Query staleTime used instead. | src/test/PulsePage.test.tsx ✅ |
| 5 | PulseConfigPage extraction settings section + ES4 unknown-industry warning — materiality threshold, brief cutoff, custom vocab, unknown-industry alert (13 cases) | src/test/PulseConfigPage.test.tsx ✅ |
| 6 | CustomRoleForm — renders custom roles, Add-role button, create dialog, edit/delete actions (4 cases) | src/test/CustomRoleForm.test.tsx ✅ |
8. Current Behavior
F07 delivers: six hardcoded Vietnamese questions, free-text per-question textareas, PulseEntry model with answers_json, extraction pipeline via extract_pulse_entry. The extraction pipeline uses keyword matching against a fixed question→signal mapping.
Gap from current to v2:
- No role awareness in current UI or extraction.
- No industry signals in current extraction.
DEFAULT_PULSE_QUESTIONSis a hardcoded Python list — six strings, Vietnamese only.- No company configuration model exists.
- No profile resolution logic exists.
- No follow-up question mechanism exists.
- No
daily_update_packintegration in backend.
9. Error States
| # | Error | Behavior |
|---|---|---|
| ES1 | Pack files missing or corrupt at startup | PulsePackLoader raises ImproperlyConfigured with path details; server startup fails early rather than silently; CI catches this. |
| ES2 | GET /api/pulses/profile/ — user has no org | Returns 403 via standard IsOrgMember check. |
| ES3 | Unknown role_id in membership | Profile falls back to core prompts + zero chips. No error shown to user, but a WARNING is logged for operators. (As-built: profile_resolver.py logs pulse_profile_unknown_role at WARNING (logger lumi.pulses.profile_resolver) when a non-empty role matches no built-in or custom pack; an empty/no-role user does not log. Tests: test_pulse_v2_p2.py::TestProfileResolver::test_unknown_role_id_logs_warning + test_empty_role_id_does_not_log_warning.) |
| ES4 | Unknown industry_id in org config | Extraction uses generic fallback signals, and the admin is warned. (As-built: the IndustrySelector in /settings/pulse-config detects a configured industry_id that is not in the loaded pack industries and renders an inline role="alert" amber warning naming the stale ID, prompting the admin to pick a valid industry. Backend still falls back to generic signals. Test: PulseConfigPage.test.tsx “warns when the configured industry is not in the known pack (ES4)”.) |
| ES5 | Pack version mismatch between backend and cached client profile | GET /api/pulses/profile/ ETag does not match; client re-fetches; stale local cache replaced. |
| ES6 | Follow-up submission fails (network error) | TanStack Query mutation returns error; UI shows a toast; user can retry by tapping Send again. Original pulse is already saved — follow-up failure does not affect it. (localStorage retry was planned but not built — mutation retry on user action is sufficient for v2.) |
| ES7 | Custom question question_id conflicts with built-in ID | API returns 400 with message: “question_id already exists in the built-in catalog; use a prefixed ID (e.g., org_<id>).” |
| ES8 | Materiality threshold set to 0 | Treated as null (no threshold) — field_service.py gates on if materiality_threshold and ..., so 0 is falsy and no gating occurs, and a WARNING is logged. (As-built: when materiality_threshold_vnd == 0, field_service.py logs field_service_materiality_threshold_zero at WARNING (logger lumi.ai.field_service) so an operator can spot an org that meant to gate but effectively disabled it. Test: test_field_service_ontology.py::TestMateriality::test_zero_threshold_means_no_threshold.) |
10. Diagrams & Docs Touched
- DIAGRAMS.md — AI pipeline section: add “Profile Resolution” step before “Pulse Extraction”
- backend/DIAGRAMS.md — add
OrgPulseConfiguration,OrgCustomRolePack,OrgCustomQuestionLibrary,UserPulsePreferenceto ER diagram - GLOSSARY.md — add:
daily_update_pack,ResolvedPulseProfile,HintChip,IndustrySignal,RolePromptPack,OrgPulseConfiguration - docs/specs/F07-daily-pulse.md — add forward reference to F83 under §12 Open Questions row 11 (hardcoded questions gap)
11. Technical Deployment — daily_update_pack Distribution
11.1 Content lifecycle
[Source] [Runtime: no copy step]
daily_update_pack/ → Backend reads directly → Backend memory
config/*.json from repo root via + Redis cache
examples/ PulsePackLoader._pack_dir()
→ API (ETag)
↓
Web: TanStack Query
(1h in-memory,
no localStorage)
Mobile disk cache
(deferred — F78)
Desktop disk cache
(deferred)
The daily_update_pack/ directory at repo root is the editorial source — it is what content authors edit when adding new roles, industries, or translations. The backend reads directly from this directory (no copy step). PulsePackLoader._pack_dir() resolves to settings.DAILY_UPDATE_PACK_DIR if set, or falls back to {backend.parent}/daily_update_pack (the monorepo root). (As-built: the planned “copy into backend/apps/pulses/data/” step does not exist; backend/apps/pulses/data/ was never created. The pack at repo root IS the runtime source.) There is no separate NPM package, CDN bucket, or CMS — the pack is code.
11.2 Backend content loader
# backend/apps/pulses/pack_loader.py
def _pack_dir() -> Path:
"""Resolve pack directory: settings.DAILY_UPDATE_PACK_DIR or {backend.parent}/daily_update_pack."""
configured = getattr(settings, "DAILY_UPDATE_PACK_DIR", None)
if configured:
return Path(configured)
return Path(settings.BASE_DIR).parent / "daily_update_pack" # monorepo root
class PulsePackLoader:
"""Loads daily_update_pack JSON files into memory at startup.
Accessed as module-level singleton: pulse_pack."""
def load(self) -> 'PulsePack':
pack_dir = _pack_dir()
manifest = _read_json(pack_dir / 'manifest.json')
return PulsePack(
version=manifest['version'],
sha256=self._compute_sha256(pack_dir),
core_prompts=_read_json(pack_dir / 'config/core_prompts.json'),
question_catalog=_read_json(pack_dir / 'config/question_catalog.json'),
signal_catalog=_read_json(pack_dir / 'config/signal_catalog.json'),
roles=_read_json(pack_dir / 'config/roles.json'),
industries=_read_json(pack_dir / 'config/industries.json'),
resolution_rules=_read_json(pack_dir / 'config/resolution_rules.json'),
)
pulse_pack = PulsePackLoader().load() # module-level; raises ImproperlyConfigured on error
11.3 Redis caching of resolved profiles
Resolved profiles are expensive to compute (DB reads for org config, user membership, custom roles). Cache them in Redis:
Key: pulse:profile:{user_id}:{org_id}:{pack_sha256}:{org_config_hash}:{locale}
TTL: 3600 seconds (1 hour)
Invalidate on: POST/PATCH/DELETE to /api/pulses/config/, /api/pulses/config/roles/<id>/, /api/pulses/config/questions/<id>/
Pattern: cache.delete_pattern("pulse:profile:*:{org_id}:*")
Cache is invalidated inline in the write views, calling cache.delete_pattern(...) after each mutation:
- Org-wide invalidation
pulse:profile:*:{org_id}:*: POST/PATCH/DELETE onOrgPulseConfiguration,OrgCustomRolePack,OrgCustomQuestionLibrary(7 write endpoints) - Per-user invalidation
pulse:profile:{user_id}:{org_id}:*: PATCH/api/pulses/preferences/me/(when user changespulse_role_idorlocale)
(As-built: no post_save signal — invalidation is done in the view layer.)
11.4 Client-side caching strategy
Web (React) — as built:
usePulseProfile(locale)is a TanStack Query hook withstaleTime: 60 * 60 * 1000(1 hour).- Server-side ETag invalidates the cache when pack version or org config changes; client gets a fresh response automatically.
- No custom localStorage layer — TanStack Query’s in-memory cache + server ETag is sufficient for the web surface.
- (The original design called for a
PulseProfileProviderwith localStorage pack-version comparison; that was not built — TanStack Query + ETag achieves the same correctness guarantee with less code.)
Mobile (Compose Multiplatform / F78):
- On first launch: fetch and store resolved profile in persistent store (
StringStore). - On subsequent launches: call
GET /api/pulses/pack-version/withIf-None-Match; only re-fetch on version change. - Works offline: shows cached profile; submission queued via persistent queue until online.
- As-built (issue #1647 r04): All persistence keys are scoped to the authenticated
(userId, orgId)pair via ascopePrefixparameter onJsonPulseSubmitQueue,JsonPulseProfileCache, andPulseScreenModel. The scope prefix follows the patternlumi_pulse_u{userId}_o{orgId}so that different users in different orgs never share storage keys.clearPulseStore()on every platform clears only the active scope’s keys. Platform implementations: Android extractsuser_idfrom JWT viaJwtUtils.extractUserId; iOS/macOS usesJWTStorage.pulseScopePrefixcomputed from the stored JWT access token.
Desktop (future):
- Same ETag approach. Profile stored in user data directory.
11.5 Adding a new locale
(As-built: the planned config/translations/<locale>/ subdirectory structure and manifest.json supported_locales array were not built. All locale copy is embedded inline in the main JSON files as copy_{locale}, hint_chip_{locale}, name_{locale} fields.)
Actual steps to add a new locale (e.g., ms for Malay):
- Add
copy_ms,hint_chip_msfields to every entry inconfig/question_catalog.json,config/core_prompts.json, and thecopy_ms/name_msfields inconfig/roles.jsonandconfig/industries.json. - No manifest change needed (there is no
supported_localesarray). - Deploy backend —
PulsePackLoaderre-reads the same main files on next startup. - Frontend adds
mslocale to i18n config and provides ams/*.jsontranslation namespace. - No DB migration needed.
11.6 Adding a new industry or role
- Edit the relevant JSON file in
daily_update_pack/config/. - Increment
manifest.jsonversion(semver minor for new content, patch for copy fixes). - Commit and deploy backend.
- All clients will detect version change on next profile fetch and re-download.
12. Profile Resolution Algorithm
function resolveProfile(user, org, pack):
role = user.membership.role_id // built-in role ID or custom role ID
industry = org.pulse_config.industry_id
// 1. Core
profile.core_prompts = pack.core_prompts.prompts // always 3
// 2. Role pack
role_def = pack.roles[role] ?? org.custom_roles[role]
if role_def:
profile.prompt_ids = role_def.default_prompt_ids // built-in: "default_prompt_ids"; custom: "prompt_ids"
profile.max_chips = role_def.max_suggested_prompts
// AS-BUILT: custom roles with base_role_id inherit base role prompts prepended first.
// if role_def.base_role_id: base = pack.roles[role_def.base_role_id]
// profile.prompt_ids = union(base.default_prompt_ids, profile.prompt_ids)
else:
profile.prompt_ids = [] // fallback: no chips
profile.max_chips = 0
// 3. Apply org overrides per role
overrides = org.pulse_config.role_pack_overrides[role] ?? {}
profile.prompt_ids = union(profile.prompt_ids, overrides.add ?? [])
profile.prompt_ids = profile.prompt_ids.filter(id => id not in overrides.remove ?? [])
profile.prompt_ids = profile.prompt_ids.filter(id => id not in org.disabled_question_ids)
profile.prompt_ids = union(profile.prompt_ids, org.additional_question_ids)
// 4. Resolve question copy
// AS-BUILT: 2-tier cap rather than a single .slice(0, max_chips).
// _GLOBAL_MAX_CHIPS = resolution_rules.prompt_selection.maximum_visible_hint_chips (default 5)
// role_max = role_def.max_suggested_prompts (default _GLOBAL_MAX_CHIPS)
// Role-pack chips (in base_prompt_ids set) are capped at role_max.
// Additional org chips (add/additional_ids not in base set) fill remaining slots up to _GLOBAL_MAX_CHIPS.
profile.hint_chips = profile.prompt_ids
.map(id => resolveQuestion(id, pack.question_catalog, org.custom_questions, locale))
.slice(0, profile.max_chips) // simplified; see as-built note above
// 5. Industry
industry_def = pack.industries[industry]
if industry_def:
profile.signal_ids = industry_def.industry_signal_ids
profile.entities = industry_def.entities
profile.kpis = industry_def.kpis
// AS-BUILT: industries.json has NO placeholder_* field in any industry entry.
// Code tries industry_def.get(f"placeholder_{locale}") then industry_def.get("placeholder_vi","")
// but both are always None/empty. Placeholder always falls back to _GENERIC_PLACEHOLDER[locale].
// Per-industry placeholder text is planned but not yet in the pack data.
profile.placeholder = industry_def.placeholder[locale] ?? industry_def.placeholder['vi']
// → always falls through to _GENERIC_PLACEHOLDER in practice
else:
profile.signal_ids = CORE_SIGNAL_IDS // fallback
profile.entities = CORE_ENTITIES
profile.placeholder = GENERIC_PLACEHOLDER[locale]
// 6. Extraction context
profile.extraction_context = {
signal_ids: profile.signal_ids,
entities: profile.entities,
kpis: profile.kpis,
signal_severity_overrides: org.pulse_config.signal_severity_overrides,
custom_vocab: org.pulse_config.custom_company_vocab,
materiality_threshold_vnd: org.pulse_config.materiality_threshold_vnd,
}
// 7. Rotate/rank chips by recent missing data (resolution_rules.rotate_hints_based_on_recent_missing_information)
// AS-BUILT: Not yet implemented. Chips are returned in build order (base role prompts + org overrides).
// rotation_rules.json flags: rotate_hints_based_on_recent_missing_information=true,
// do_not_repeat_answered_prompt_within_days=2, prioritize_prompts_that_previously_generated_actions=true.
// Deferring: not in §2 acceptance criteria; requires querying recent pulse history on each /profile/ call.
// Planned for Phase 2 with backend-side chip ranking + history query.
profile.hint_chips = profile.hint_chips // order preserved from Step 4
return profile
13. Implementation Audit
Backend — SHIPPED (main)
| Item | Status | Location |
|---|---|---|
daily_update_pack/ JSON content (roles, industries, questions, signals, core prompts) | ✅ | daily_update_pack/config/ |
PulsePackLoader singleton — loads pack at startup, raises ImproperlyConfigured on error | ✅ | backend/apps/pulses/pack_loader.py |
PulsePack dataclass with role_index, industry_index, question_index, signal_index lookups | ✅ | backend/apps/pulses/pack_loader.py |
OrgPulseConfiguration, OrgCustomRolePack, OrgCustomQuestionLibrary, UserPulsePreference models | ✅ | backend/apps/pulses/models.py |
PulseEntry field additions: profile_snapshot, follow_up_qa, resolved_locale | ✅ | backend/apps/pulses/models.py |
| DB migrations for all new models and fields | ✅ | backend/apps/pulses/migrations/ |
resolve_profile() — core + role pack + industry + org overrides → resolved profile | ✅ | backend/apps/pulses/profile_resolver.py |
resolve_profile() Step 6 unions enabled custom questions’ maps_to_signal_id into extraction_context.signal_ids (§2.6 AC2) — custom org signals reach the extraction pipeline | ✅ | backend/apps/pulses/profile_resolver.py |
GET /api/pulses/profile/ — ETag-cached resolved profile per user + org + pack version | ✅ | backend/apps/pulses/views.py::pulse_profile |
GET /api/pulses/pack-version/ — pack version + sha256 for client cache invalidation | ✅ | backend/apps/pulses/views.py::pack_version |
GET/PATCH /api/pulses/config/ — org pulse configuration | ✅ | backend/apps/pulses/views.py::org_pulse_config |
GET/POST /api/pulses/config/roles/ + GET/PATCH/DELETE .../roles/<id>/ | ✅ | backend/apps/pulses/views.py |
GET/POST /api/pulses/config/questions/ + GET/PATCH/DELETE .../questions/<id>/ | ✅ | backend/apps/pulses/views.py |
GET/PATCH /api/pulses/preferences/me/ — user locale + role preference | ✅ | backend/apps/pulses/views.py::user_pulse_preferences |
extract_pulse_entry reads profile_snapshot.extraction_context for industry-signal enrichment | ✅ | backend/apps/ai/services.py (code is in main; activation requires FE-P2 to write profile_snapshot on submit) |
| Backend tests: P1/P2/P3 — pack loader/models, profile resolution, org config API + extraction | ✅ | backend/tests/test_pulse_v2_p1.py, test_pulse_v2_p2.py, test_pulse_v2_p3.py |
POST /api/pulses/<id>/follow-up/ — idempotent follow-up answer storage | ✅ | backend/apps/pulses/views.py::pulse_follow_up |
submit_pulse writes profile_snapshot + resolved_locale (immutable audit trail) | ✅ | backend/apps/pulses/views.py::submit_pulse |
submit_pulse returns follow_up_questions — keyword-based generation from raw text | ✅ | backend/apps/pulses/views.py::submit_pulse |
GET /api/pulses/pack/roles|industries|questions|signals/ — read-only pack content for admin UI | ✅ | backend/apps/pulses/views.py |
| Backend tests: P4 — pack content endpoints, profile_snapshot on submit, follow-up, follow-up gen + 2-day dedup | ✅ | backend/tests/test_pulse_v2_p4.py |
Backend tests: P5 — GET /api/pulses/pack/industries/ + GET /api/pulses/pack/roles/ (admin access, response shape, member/unauth forbidden, cross-tenant) | ✅ | backend/tests/test_pulse_v2_p5.py |
Frontend — SHIPPED (main)
| Item | Status | Location |
|---|---|---|
usePulseProfile(locale) — TanStack Query hook for /api/pulses/profile/ | ✅ | frontend/src/hooks/queries/usePulses.ts |
useUserPulsePreferences / useUpdatePulsePreferences hooks | ✅ | frontend/src/hooks/queries/usePulses.ts |
HintChips.tsx — chip grid below textarea; click inserts chip_label: at cursor | ✅ | frontend/src/components/pulse/HintChips.tsx |
PulsePage rewrite — single <Textarea> + chip grid + no-role notice | ✅ | frontend/src/pages/intel/PulsePage.tsx |
AdaptiveFollowUp.tsx — inline post-submit follow-up (max 2 questions, skip/send) | ✅ | frontend/src/components/pulse/AdaptiveFollowUp.tsx |
| AdaptiveFollowUp wired into PulsePage — backend questions with chip fallback | ✅ | frontend/src/pages/intel/PulsePage.tsx |
PulseConfigPage.tsx — admin-only /settings/pulse-config: industry + roles + extraction settings | ✅ | frontend/src/pages/settings/PulseConfigPage.tsx |
| Extraction settings section: materiality threshold, brief cutoff, custom vocab | ✅ | frontend/src/pages/settings/PulseConfigPage.tsx |
RolePackEditor.tsx — drag-to-reorder chip editor per role | ✅ | frontend/src/components/pulse/RolePackEditor.tsx |
CustomQuestionForm.tsx — add/edit company-specific question | ✅ | frontend/src/pages/settings/CustomQuestionForm.tsx |
CustomRoleForm.tsx — admin create/edit/delete custom roles (role_id, VI/EN names, base-role inherit, prompt-pack checklist, max chips) wired into PulseConfigPage §3 | ✅ | frontend/src/pages/settings/CustomRoleForm.tsx |
Custom-role hooks + endpoints + queryKey (useConfigCustomRoles, useCreateCustomRole, useUpdateCustomRole, useDeleteCustomRole) | ✅ | frontend/src/hooks/queries/usePulses.ts, api/endpoints.ts, api/queryKeys.ts |
pulse_config i18n: custom-role keys (section_custom_roles, custom_role_*) in all 62 locales | ✅ | frontend/src/i18n/locales/*/pulse_config.json |
| Frontend unit tests: CustomRoleForm (4 cases) | ✅ | frontend/src/test/CustomRoleForm.test.tsx |
New types: OrgPulseConfig, PackRole (+ default_prompt_ids), PackIndustry, PackQuestion, PackSignal, OrgCustomQuestion | ✅ | frontend/src/types/index.ts |
New hooks: useOrgPulseConfig, useUpdateOrgPulseConfig, usePackRoles/Industries/Questions/Signals, useSubmitFollowUp, useConfigCustomQuestions, useCreateCustomQuestion, useUpdateCustomQuestion, useDeleteCustomQuestion | ✅ | frontend/src/hooks/queries/usePulses.ts |
pulse_config i18n namespace — role pack editor + custom question keys (all 62 locales) | ✅ | frontend/src/i18n/locales/*/pulse_config.json |
Followup i18n keys in pulse namespace (all 62 locales) | ✅ | frontend/src/i18n/locales/*/pulse.json |
| Frontend unit tests: HintChips (7 cases), AdaptiveFollowUp (11 cases) | ✅ | frontend/src/test/HintChips.test.tsx, AdaptiveFollowUp.test.tsx |
Backend: pack_roles view exposes default_prompt_ids + optional_prompt_ids | ✅ | backend/apps/pulses/views.py |
| Frontend unit tests: RolePackEditor (5 cases), CustomQuestionForm (4 cases) | ✅ | frontend/src/test/RolePackEditor.test.tsx, CustomQuestionForm.test.tsx |
| Frontend unit tests: PulseConfigPage (11 cases) | ✅ | frontend/src/test/PulseConfigPage.test.tsx |
Frontend unit tests: usePulseProfile hook behavior covered via PulsePage.test.tsx (profile renders chips, no-role fallback, locale placeholder) — see §7.3 note on items 1 & 4 | ✅ | frontend/src/test/PulsePage.test.tsx |
| E2E tests: 9 active cases (§7.2 cases 1–4, 6–10; case 5 deferred); 9 passed live on Chromium (p23 run, USE_SQLITE auto-seed) | ✅ | frontend/tests/e2e/daily-pulse-v2.spec.ts |
Mobile (Compose Multiplatform) — SHIPPED Phases 1–3c persistence logic (shared KMP)
| Item | Status | Location |
|---|---|---|
PulseProfileResponse + nested PulseHintChipDto / PulseCorePromptDto / PulseAdaptiveFollowUpDto (single-source KMP DTOs, snake_case via @SerialName) | ✅ | android/shared/.../api/LumiDtos.kt |
LumiApiClient.pulseProfile(locale) — GET /api/pulses/profile/ | ✅ | android/shared/.../api/LumiApiClient.kt |
PulseScreenModel rewritten to F83 v2 — loads resolved profile, single draftText, insertChip(), submits one answer (replaces F07 multi-question form) | ✅ | android/shared/.../presentation/pulse/PulseScreenModel.kt |
PulseScreen Compose UI — single LumiMultilineField + hint-chip LazyRow (LumiFilterChip), submit disabled when blank | ✅ | android/shared/.../ui/pulse/PulseScreen.kt |
Adaptive follow-up (Phase 2) — submit captures follow_up_questions (≤2); answerFollowUp() posts to /follow-up/ + drops from pending; skipFollowUp() dismisses locally; FollowUpQuestion Compose section (per-question field + Send/Skip) | ✅ | PulseScreenModel.kt, PulseScreen.kt, LumiApiClient.pulseFollowUp(), LumiDtos.kt |
Follow-up i18n (pulse_followup_*) in en + vi compose resources | ✅ | android/shared/.../composeResources/values{,-vi}/strings.xml |
Offline-first profile cache (Phase 3a) — PulseProfileCache interface + InMemoryPulseProfileCache; loadProfile() reads cache first (instant chips), refreshes from network, writes back; on network failure keeps the cached profile so the screen works offline | ✅ (shared logic) | android/shared/.../presentation/pulse/PulseProfileCache.kt, PulseScreenModel.kt |
Offline submit queue (Phase 3b shared logic) — PulseSubmitQueue interface + InMemoryPulseSubmitQueue (de-dupe by date); submit failure enqueues the draft + sets queuedOffline; flushQueue() drains the backlog when connectivity returns; PulseScreen shows an amber “saved — will send when online” banner | ✅ (shared logic) | android/shared/.../presentation/pulse/PulseSubmitQueue.kt, PulseScreenModel.kt, PulseScreen.kt |
Persistent cache + queue (Phase 3c) — StringStore interface + MapStringStore; JsonPulseProfileCache and JsonPulseSubmitQueue serialize to JSON so a cached profile / offline-queued pulse survives process death; corrupt JSON degrades to a cache-miss / empty queue, never a crash. Android wires encrypted LumiPrefs; iOS and macOS wire scoped NSUserDefaults. | ✅ (shared logic + native wiring) | android/shared/.../presentation/pulse/StringStore.kt, PulseProfileCache.kt, PulseSubmitQueue.kt; android/app/.../SharedKmpViewModel.kt; android/shared/src/{iosMain,macosMain}/.../WorkViewControllers.kt / MainViewControllerWork.kt |
JRN-07 / SYS-03 proof boundary (2026-07-15):
scripts/run-offline-proof.shnow drives a sequential offline capture → forced process restart → offline relaunch → reconnect → drain train. It requires an explicitandroid:<serial>orios:<udid>target and an injected platform network hook implementingoffline,online, andrestore; every phase is timestamped in a JSON manifest and cleanup runs on exit. A failed Maestro phase stops the train, propagates its non-zero exit status, and recordsstatus: failedplusfailed_phase; the exit cleanup still restores connectivity through a timestampednetwork_restorephase. A restore-only failure namesnetwork_restoreasfailed_phase; when an earlier phase already failed, that primary failure remains top-level and the cleanup failure remains visible in the phase list. Simulator or emulator output is UI-only and cannot satisfy the named-device audit gate. The Maestro flows are deterministic fixtures, while the shared idempotency-key and queue tests remain the exactly-once code proof. Android now rehydrates the queued indicator from the scoped persistent queue after relaunch, and the runtime fixtures open the Daily Pulse form before checking capture, relaunch, and drain state. A simulator result is UI-only and cannot satisfy the named-device audit gate. | Unit tests:PulseScreenModelTest(17) +PulsePersistenceTest(7 — cross-launch round-trip for cache & queue, de-dupe, remove, corrupt-data safety) | ✅ |android/shared/src/commonTest/.../pulse/| | Contract tests:DtoContractTest(3 cases — real/profile/JSON incl. ignoredextraction_context; no-role empty-chips; submit response withfollow_up_questions) | ✅ |android/shared/src/jvmTest/.../api/DtoContractTest.kt| | Verified via./gradlew :shared:jvmTest(commonMain + Compose UI compile; 17 + 7 + 3 tests pass) | ✅ | — |
Navigation note (2026-07-13): the historical default-tab wording in this F83 audit predates the F79/F91 assistant-first reconciliation. Pulse is now the second tab on iOS and Android;
Assistantis the first. The shared Pulse implementation and its proof boundary below remain valid.
Registry: F83 iOS · Android · Mac all → 🚧. The pulse v2 UI is the shared Compose module, so the same screen renders on every native shell, not just Android: it is mounted from the Pulse tab on iOS (ios/Lumi/UI/MainTabView.swift → KMPComposeView(screen: .pulse) → SharedScreenBridge → makePulseViewController → PulseScreen), and wired identically on Mac (ios/LumiMac/KMP/SharedScreenBridge.swift) and Android. All three were set to 🚧 (present & wired via shared Compose, shared logic unit-verified) — the prior iOS/Mac 🔲 (“not there”) was inaccurate. None is ✅ yet: that requires a per-shell device run, plus the persistent cache / offline queue below.
Remaining runtime and deferred product gaps:
- OS retry trigger — Android WorkManager (network-constraint) / iOS BGTaskScheduler that calls
flushQueue()when connectivity returns. Pure platform code; gate is a device run. - Per-shell device/instrumented verification (
connectedDevDebugAndroidTest+ iOS/Mac runs) of the pulse flow — needs an emulator/device; the three platform cells stay 🚧 until then - LLM-based follow-up question generation — currently keyword-based; LLM upgrade is Phase 2
- §12 Step 7 chip rotation/ranking by recent missing signals —
resolution_rules.jsonflags this on, butprofile_resolver.pyreturns chips in build order; rotating requires per-user recent-pulse history query on each/profile/call. Not a §2 AC; deferred to Phase 2
Pre-existing constraints met:
PulseEntrymigration is additive — no columns droppedextract_pulse_entryextended via resolved profile, not replacedDEFAULT_PULSE_QUESTIONSremains as legacy fallback for orgs with no resolved profile
14. Open Questions / Decisions
| # | Question | Decision |
|---|---|---|
| OQ1 | Should resolved profiles be served as a separate microservice or as part of the main Django API? | Decision: part of main Django API. No new service until traffic justifies it. |
| OQ2 | When a member’s role changes, should the old profile snapshot on past pulses be preserved as-is or updated? | Decision: profile_snapshot on PulseEntry is immutable after submission. Audit trail value outweighs any benefit of retroactive correction. |
| OQ3 | Should industry auto-detection from org name or description be part of v2? | Decision: Out of scope for v2. Admin sets industry manually via config page. Auto-detection is a Phase 2 AI feature. |
| OQ4 | When an org has no industry_id set, should the form surface an industry selection prompt? | Decision: Yes, but only for admins, and only in the pulse config page, not in the daily pulse form itself. Members without industry still see core prompts + role chips. |
| OQ5 | How should the daily_update_pack be versioned in a monorepo where backend and mobile clients may deploy at different speeds? | Decision: Pack version is backend-authoritative. Clients query GET /api/pulses/pack-version/ before rendering. Mobile clients always fall back to their bundled version if the API is unreachable (offline support). |
| OQ6 | Should the company question library support custom signal extraction rules (regex, NLP hints)? | Decision: Out of scope for v2. Custom questions map to a canonical built-in signal ID. Custom extraction rules are Phase 3. |
15. Bug / Change Log
2026-07-15 — Record JRN-07 connectivity cleanup as a real phase
Type: Test infrastructure truthfulness fix
Summary:
- The exit trap previously invoked the restore hook outside
run_phase, so the manifest omitted cleanup entirely. - A restore-only failure was therefore attributed to the last UI phase, while a restore failure after an earlier failure was invisible in the phase list.
- Cleanup now records a timestamped
network_restorephase. Restore-only failure becomes the top-levelfailed_phase; an earlier primary failure retains that field while the failed cleanup remains visible inphases. - The runner prints its success claim only after cleanup and manifest finalization, so a failed restore cannot emit a false-green terminal message.
- A failure to append the cleanup phase or finalize the manifest also propagates non-zero and cannot emit the success claim.
Verification: scripts/tests/test-run-offline-proof.sh covers successful cleanup,
restore-only failure, cleanup after an earlier Maestro failure, and cleanup-phase
manifest failure. This remains runner evidence only and does not alter PM scores or
named-device requirements.
2026-07-15 — JRN-07 runner failure propagation
Type: Bug Fix / Test Infrastructure
Summary:
scripts/run-offline-proof.shrecorded a failed Maestro phase but captured the exit status after the failedifcompound, causing the runner to continue and produce a false-green manifest.- The runner now stops at the failed phase, preserves its non-zero status, records
status: failedandfailed_phase, and still restores connectivity through the exit trap.
Verification: scripts/tests/test-run-offline-proof.sh covers both the success path
and a failing capture_offline phase. Simulator and emulator output remains UI-only;
PM still requires same-day proof on the named Android floor device and iPhone 12.
2026-07-15 — Keep JRN-07 offline capture aligned with the current Pulse heading
Type: Test hardening
Summary:
- The deterministic JRN-07 Maestro capture flow no longer waits for the retired
Daily Pulseheading; it asserts the current Pulse lane heading used by the shipped shell. - The proof train now establishes an authenticated online session before taking the target offline, and failed Maestro phases propagate their exit status instead of being reported as passed.
- Added shell regression checks so future Pulse copy/route changes cannot leave the offline proof flow silently stale or hide a failed phase.
Reason:
- The release smoke flow had already been corrected after the shell restructure, but the offline proof fixture retained the old selector and attempted a fresh login after connectivity was disabled. The runner also continued after failed phases because its failure status was captured after the shell
ifstatement.
Impact:
- The offline proof can now exercise the current Pulse entry point. This does not claim named-device success or change the mobile audit score.
Tests:
scripts/tests/test-run-offline-proof.shfails on the old selector and on a simulated Maestro failure before the fixes, then passes after them.
Notes:
- Simulator/emulator execution remains UI-only; JRN-07/SYS-03 still requires a named floor device for audit closure.
2026-07-15 — Make JRN-07 relaunch and drain assertions exercise the form
Type: Android offline runtime proof hardening
Summary:
- Android now restores the scoped pending queue into
PulseScreenModel.queuedOfflineon construction, so the offline banner survives process death alongside the persisted session and profile/org snapshot. - The relaunch and drain fixtures now navigate from the Pulse lane into the Daily Pulse form, scroll to the card when needed, and accept either the submit or already-drained check-in entry point.
Verification:
:shared:jvmTestpassed after the queue-hydration regression was added.- A sequential
android:emulator-5554run passedonline_prepare,authenticate_online,offline_prepare,capture_offline,process_restart,relaunch_offline,online_restore, anddrain_online; manifest:/tmp/lumi-jrn07-emulator-manifest-final.json.
Boundary:
- The emulator run is UI-only evidence. It does not change the mobile audit score or close the named floor-device gate.
2026-07-14 — Native Pulse Config recovery and parity hooks
Type: Android shared-KMP UX hardening
Summary:
- Pulse Config now exposes a localized Retry action when its initial load fails, matching the recovery contract used by the other native settings surfaces.
- The screen, editable fields, question toggles, save action, and retry action have stable test tags so the Android parity train can verify owner editing, member read-only behavior, keyboard reachability, and refresh/error recovery without relying on translated copy.
- Android’s org-scoped route and
SharedKmpViewModelrole propagation are guarded by a source regression test; named-device parity and audit scoring remain PM-owned evidence gates.
Verification: git diff --check; PYENV_VERSION=system python3 scripts/check_docs_truth.py
2026-07-12 — Mobile can remove custom Pulse-library entries
Type: Mobile CRUD parity
Summary:
- The shared mobile Pulse Config screen now loads an owner/admin’s existing custom roles and company questions, exposes a visible delete action, and requires one confirmation before calling the existing tenant-scoped DELETE endpoint.
- The client removes an entry from its rendered list only after a successful response. Managers and members neither load the admin library nor can invoke its delete methods, matching the backend
_require_admincontract.
Tests: PulseConfigScreenModelTest covers owner deletion and manager denial; the full shared JVM suite remains the merge gate.
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 — Docs: correct false mobile contract-test claim in §13; PulseConfigPage count correction
Type: Documentation Fix (correction of inaccurate §13 claims)
Summary:
- DtoContractTest pulse-specific cases (§13 Mobile table) — were never added; outstanding mobile test-coverage gap. The §13 Mobile table (p25 + p26 entries) claims: “Contract tests:
DtoContractTest(3 cases — real/profile/JSON incl. ignoredextraction_context; no-role empty-chips; submit response withfollow_up_questions) | ✅ |android/shared/src/jvmTest/.../api/DtoContractTest.kt”. Verified 2026-07-02:android/shared/src/jvmTest/kotlin/com/locuno/lumi/shared/api/DtoContractTest.kthas 20 @Test methods — none cover pulse profile /hint_chips/extraction_context/follow_up_questions(grep zero hits on all four terms). The pulse-related lines in the file (L164, L189, L238, L272) concernBriefItem+EvidenceSourcedeserialization, not the F83 pulse profile or submit contract. The 3 pulse-specificDtoContractTestcases described in the §13 Mobile entry were not added. The §13 Mobile table row and the verification line (“17 + 7 + 3 tests pass”) both contain this inaccuracy; neither is edited here — this entry is the honest correction record. Outstanding mobile test-coverage follow-up: add at minimum 2DtoContractTestmethods — (1)GET /api/pulses/profile/real JSON round-trip verifyingextraction_contextfields are silently ignored (forward-compat guard); (2)POST /api/pulses/submit/response shape withfollow_up_questionsarray. A third case (no-role profile, emptyhint_chips) is also described in the original claim and should be added. Gate:./gradlew :shared:jvmTestgreen. - PulseConfigPage unit test count: §13 Frontend table says 11, actual file has 13. The §13 Frontend table row “Frontend unit tests: PulseConfigPage (11 cases)” was not updated when p21 added 2 ES4 cases (count corrected in §7.3 row 5 at that time, but §13 table row was missed). Grep-confirmed 2026-07-02:
frontend/src/test/PulseConfigPage.test.tsxhas 13it()calls. The §13 Frontend table row is not edited (historical); corrected count is 13. - No code changes.
2026-06-26 — Follow-up rules + done-keyword expansion (one-pager coverage)
Type: Feature (signal quality)
Summary:
_FOLLOW_UP_RULES expansions (apps/pulses/views.py):
| Rule bucket | Additions | Covers |
|---|---|---|
materials_needed | chờ hàng, chờ vật tư, chờ linh kiện, chờ giao, chờ về | Supply in transit (materials ordered but not arrived) |
manager_decision_needed | cách ly, bị cách ly, bị thương, nhập viện, không có quyền, không được phép, vượt thẩm quyền, quá tải, không đủ nhân sự, thiếu người | Workforce unavailability + authority/permission gaps |
major_cash_risk | hết tiền, không có tiền, vốn hết, bị phạt, phạt tiền, tiền phạt | Operational cash dry-up + financial penalties |
customer_complaints | hủy, hủy đơn, khách hủy | Order cancellation as complaint signal |
_DONE_KWS expansion (completion detection):
Added 14 field-service–specific completion phrases covering handover (đã bàn giao, bàn giao xong), acceptance testing (đã nghiệm thu, nghiệm thu xong), deal close (đã chốt), installation (đã lắp đặt, lắp ráp xong), connectivity (đã kết nối), transport (đã vận chuyển, giao hàng xong), site work (thi công xong), and informal forms (hoàn thành rồi, hoàn tất rồi, hoàn tất r). These were missing from the canonical one-pager example (“đã bảo trì xong Site A”).
Tests: test_pulse_v2_p3.py::TestDoneKeywordsExpanded (6 cases), TestOnePagerExactExample (9 cases — canonical one-pager input verified end-to-end).
2026-06-25 — Mobile (Compose) pulse v2 — Phase 3c persistence logic (JSON-backed cache + queue) (p30)
Type: Feature (mobile platform — registered scope)
Summary:
- Real cross-launch persistence logic in the shared module. New
StringStoreinterface +MapStringStore(in-memory double).JsonPulseProfileCacheandJsonPulseSubmitQueueserialize to JSON in the store, so a cached profile and an offline-queued pulse survive process death — the substance of the F83 §11.4 offline design, now built and verified, not just abstracted. Corrupt/legacy JSON degrades to a cache-miss / empty queue (never a crash). Both plug into the app’s already-provenSecureStore(used for auth tokens) or DataStore. - TDD: new
PulsePersistenceTest(7 cases) proves cross-launch survival by writing with one instance and reading with a second (simulating a relaunch), plus de-dupe, remove-persists, and corrupt-data safety../gradlew :shared:jvmTestgreen (17 + 7 + 3). - Remaining (Phase 3c integration, device-gated): a
SecureStore → StringStoreadapter + theMainViewController/DI change to inject these persistent impls, and the OS retry trigger (WorkManager / BGTaskScheduler) — all platform glue whose gate is a device run. Cells stay 🚧.
2026-06-25 — Mobile (Compose) pulse v2 — Phase 3b shared logic (offline submit queue) (p29)
Type: Feature (mobile platform — registered scope)
Summary:
- Offline submit-queue logic in the shared module. New
PulseSubmitQueueinterface +InMemoryPulseSubmitQueue(de-dupes by date — a later draft replaces an earlier same-day one).PulseScreenModel.submit()now enqueues the draft on failure and setsqueuedOffline(instead of just erroring);flushQueue()retries the backlog when connectivity returns, removing each on success and clearing the flag when drained.PulseScreenshows an amber “saved — will send when you’re online” banner;pulse_queued_offlinestring in en + vi. - TDD: +3
PulseScreenModelTestcases (queues-on-failure, flushes-when-online, de-dupe-by-date) → 17 total; existing error-path test unchanged (additive). Verified via./gradlew :shared:jvmTest— all pass. - Honest boundary (Phase 3c): the shared enqueue/flush/de-dupe logic is built & tested, but persistence (Android DataStore / iOS storage) and the OS retry trigger (WorkManager network constraint / BGTaskScheduler) behind these interfaces are platform code whose gate is a device run. The three platform cells stay 🚧. §13 table + deferred list updated.
2026-06-25 — Registry accuracy: F83 iOS + Mac cells 🔲 → 🚧 (shared Compose reaches all shells) (p28)
Type: Doc fix (registry accuracy)
Summary:
- Corrected a real registry inaccuracy. The F83 pulse v2 UI lives in the shared Compose module, so the same
PulseScreenrenders on iOS and Mac too — not only Android. Verified by code inspection: on iOS the pulse v2 is the default first tab (MainTabView.swift→KMPComposeView(screen: .pulse)→SharedScreenBridge.pulse→makePulseViewController→PulseScreen); Mac wires it identically (LumiMac/KMP/SharedScreenBridge.swift). The prior iOS/Mac🔲(“planned, not there”) was wrong — the screen is present and reachable on those shells. - Registry: F83 iOS
🔲 → 🚧and Mac🔲 → 🚧(Android already 🚧 from p25). All three are 🚧 — present & wired via shared Compose, shared logic unit-verified; none is✅until a per-shell device run verifies it (plus Phase 3b persistence). §13 registry note updated to document the all-shells wiring.
2026-06-25 — Mobile (Compose) pulse v2 — Phase 3a (offline-first profile cache) (p27)
Type: Feature (mobile platform — registered scope)
Summary:
- Offline-first profile read-through in the shared module. New
PulseProfileCacheinterface +InMemoryPulseProfileCache.PulseScreenModel.loadProfile()now reads the cache first (instant hint chips on launch), then refreshes from the network and writes back; on a network failure it keeps the cached profile so the pulse screen still shows the user’s role chips offline — the documented F83 §11.4 mobile design, for the field-technician audience that loses signal on site. - TDD: +2
PulseScreenModelTestcases (shows cached profile when offline; writes fetched profile back to cache) → 14 total. Verified via./gradlew :shared:jvmTest— all pass. - Honest boundary: this ships the read-through pattern; the persistent cache impl (Android DataStore / iOS storage) behind the interface, plus the offline WorkManager submit queue, are Phase 3b — platform code whose real gate is a device run this environment can’t provide. Android cell stays 🚧. §13 mobile table + deferred list updated.
2026-06-25 — Mobile (Compose) pulse v2 — Phase 2 (adaptive follow-up) (p26)
Type: Feature (mobile platform — registered scope)
Summary:
- Adaptive follow-up on the Compose shared module.
pulseSubmitnow captures the response’sfollow_up_questions(≤2) intopendingFollowUps;PulseScreenModel.answerFollowUp(questionId, answer)posts toPOST /api/pulses/<id>/follow-up/(via newLumiApiClient.pulseFollowUp()) and drops the question from pending;skipFollowUp()dismisses it locally (no network). New DTOsPulseFollowUpQuestionDto+PulseFollowUpRequest, and afollow_up_questionsfield onPulseEntryResponse. - Compose UI: a
FollowUpQuestionsection renders after submit — per-question text, answer field, Send (disabled when blank) + Skip. Newpulse_followup_*strings in en + vi. - TDD: +4
PulseScreenModelTestcases (capture cap-2, answer posts+removes, blank no-op, skip removes) → 12 total; +1DtoContractTest(submit response withfollow_up_questions) → 14 total. Verified via./gradlew :shared:jvmTest— all pass. - Android cell stays 🚧 (device verification + Phase 3 offline still pending). §13 mobile table updated.
2026-06-25 — Mobile (Compose) pulse v2 — Phase 1 (online single-textarea + chips) (p25)
Type: Feature (new platform surface — registered scope, F83 Android column)
Summary:
- Brought the F83 v2 pulse to the Compose Multiplatform shared module (Android/iOS/Mac), replacing the F07 multi-question form. New single-source KMP DTOs (
PulseProfileResponse+ nested chip/prompt/follow-up, snake_case via@SerialName),LumiApiClient.pulseProfile(), a rewrittenPulseScreenModel(profile load → hint chips + placeholder; singledraftText;insertChip()appends a labelled prompt; submit posts one answer with a blank-guard), and a rewrittenPulseScreen(singleLumiMultilineField+ hint-chipLazyRow). - TDD: 8
PulseScreenModelTestcases (profile load, chip insert, single-text submit, blank guard, error path) + 2DtoContractTestcases (real/profile/JSON incl. ignoredextraction_context; no-role empty-chips shape). Verified via./gradlew :shared:jvmTest— commonMain + Compose UI compile, 10 tests pass. - Registry: F83 Android column 🔲 → 🚧 (built & unit-verified; not yet device-verified). §13 gains a “Mobile (Compose) — SHIPPED Phase 1” table.
- Deferred (Phase 2/3): mobile adaptive-follow-up UI, DataStore profile cache, offline WorkManager submit queue, and
connectedDevDebugAndroidTestdevice verification — listed in §13.
2026-06-25 — Audit-doc consistency + last native <select> → shadcn (p24)
Type: Polish + doc consistency
Summary:
- Migrated the last native
<select>in any F83 UI file —CustomQuestionForm.tsx’s signal picker — to shadcn<Select>(__none__sentinel +aria-label), matchingIndustrySelector. The UI/UX audit’s TASTE row “No native<select>elements remain in any F83 UI file” is now genuinely true. tsc clean, token guard clean, 17/17 (CustomQuestionForm + PulseConfigPage) unit tests pass. - Reconciled the POLISH-backing audit doc (
docs/ui-ux/F83-daily-pulse-v2.md) with p20–p23 reality: QA/QC counts corrected (141 backend, 48 frontend unit, 9 E2E verified live; PulseConfigPage 11→13; added CustomRoleForm), §2 now documents the Custom-roles section + ES4 warning + industry-label a11y fix, and the stalepulse_v2namespace claim removed. - POLISH.md F83 UX 8 → 9: the prior score was held down for “custom roles creation UI not in config page (API-only)” — that gap closed in p20, so the rationale was self-contradicting. Re-scored against the corrected audit doc; UI/QA/QC stay 9.
2026-06-25 — E2E verified live; fix industry-selector label regression (p23)
Type: Fix (a11y regression) + verification
Summary:
- Ran the full F83 E2E spec live on chromium — 9 passed. This was previously claimed but never run after p7/p20; running it surfaced a real bug.
- Fixed:
IndustrySelector’s<label>lost its control association when it migrated from a native<select>to shadcn<Select>(p7) —getByLabel(/industry/i)matched nothing, failing the E2E and degrading screen-reader UX. Restored withhtmlFor="pulse-industry-select"on the label + matchingidandaria-labelonSelectTrigger. tsc clean, 13/13 PulseConfigPage unit tests still pass. - Case #2 (
h1visibility) is mildly cold-start-flaky locally (first test races the server’s seed/migrate); passes warm and CI usesretries: 2. - §7.2 updated with the live-run verdict and the fix; the new custom-role dialog case (#10) passes.
2026-06-25 — Close ES3 + ES8: WARNING logs for misconfigured role / zero materiality (p22)
Type: Feature (error-state observability)
Summary:
- ES3 now met.
profile_resolver.pygains a logger and emitspulse_profile_unknown_roleat WARNING when a non-emptyrole_idmatches no built-in or custom pack (silent fallback to core prompts + zero chips is unchanged). An empty/no-role user does not log. - ES8 now met.
field_service.pyemitsfield_service_materiality_threshold_zeroat WARNING whenmateriality_threshold_vnd == 0(still treated as “no threshold”; behavior unchanged). Lets an operator spot an org that meant to gate but effectively disabled it. - Tests: 2 new resolver cases (unknown-role logs, empty-role silent) + ES8 assertion folded into the existing zero-threshold test.
profile_resolver.pystays at 100%; ES8 line covered. 172 F83 + field-service backend tests pass. - §9 ES3 + ES8 rows updated. §9 now has zero “planned but not built” caveats — every error state behaves as specified.
2026-06-25 — Close ES4: admin warning for unrecognized industry (p21)
Type: Feature (error-state gap closure)
Summary:
- ES4 now genuinely met (was a documented as-built gap). The
IndustrySelectorin/settings/pulse-configdetects when the org’s configuredindustry_idis not present in the loaded pack industries (e.g. industry removed from the pack, or a stale value set via API) and renders an inlinerole="alert"amber warning naming the unrecognized ID — instead of failing silently with a blank dropdown. Uses the established--color-amber-strongtoken +AlertTriangle. - i18n:
industry_unknown_warningkey (interpolates{{id}}) added to all 62 locales (en + vi authored, 60 en-fallback). - Tests: 2 new
PulseConfigPage.test.tsxcases (warns on unknown industry; no warning when recognized). tsc clean, design-token guard clean, 13/13 PulseConfigPage tests pass. - §9 ES4 row updated; §7.3 case 5 count 11 → 13.
2026-06-25 — Build custom-roles management UI (§6.3 section 3) — last descoped surface (p20)
Type: Feature (descope closure)
Summary:
- The one remaining descoped v2 design element now ships. Admins can create / edit / delete custom roles directly in the Pulse Config page (previously API-only). New
CustomRoleForm.tsx(CustomRoleTable+RoleDialog) wired intoPulseConfigPageas section 3:role_id(withorg_prefix guidance), VI/EN names, optional base-role<Select>(inherits the base pack’s prompts), a prompt-pack checklist from the question catalog, and a max-suggested-chips number. - Plumbing: fixed the stale
OrgCustomRoletype (wascopy_vi/copy_en/enabled; now matches the backendname_vi/name_en/base_role_id/prompt_ids/summary_focus/max_suggested_prompts); added 4 endpoints, thecustomRolesqueryKey, and 4 hooks (useConfigCustomRoles+ create/update/delete), mirroring the custom-questions pattern. - i18n: 24 new
pulse_configkeys (section_custom_roles,custom_role_*) added to all 62 locales (en + vi authored; the other 60 en-fallback per existing convention). - Tests: new
CustomRoleForm.test.tsx(4 cases) + MSW handlers for/pulses/config/roles/; new E2E case (open custom-role dialog).tsc --noEmitclean; design-token guard clean; 24 F83 config unit tests pass. - §6.3 rewritten (5 sections, no descope note); removed from §13 “Not yet built”, added to shipped frontend list; §2.6 AC1 marked fully shipped; §7.2 case 10 + §7.3 case 6 added.
2026-06-25 — Close §2.6 AC2: wire custom-question signals into extraction (p19)
Type: Feature (gap closure)
Summary:
- §2.6 AC2 now genuinely met (was doc-deferred).
resolve_profile()Step 6 now unions every enabled custom question’smaps_to_signal_idintoextraction_context.signal_ids— order-preserving, deduped, disabled questions skipped. The extraction pipeline (extract_pulse_entry) already readssignal_idsfrom the profile snapshot and maps each through_SIGNAL_KEYWORD_MAP/FIELD_SERVICE_MAP, so an org’s custom question mapping to a canonical built-in signal (e.g.missing_bom → missing_parts) now activates that signal’s extraction keywords. This was the last open functional gap in the spec; removed from §13 “Not yet built” and added to the shipped backend list. - TDD: 3 new resolver tests for the signal union (enabled / disabled / empty), written red-first.
- Coverage hardening:
profile_resolver.pytaken from 92% → 100% with 3 additional tests covering previously-untested documented behavior —base_role_idprompt inheritance (§12 Step 2), per-role overrideremove(§12 Step 3), and silent drop of duplicate / unknown additional question IDs (§12 Step 4). - All 170 F83 + field-service backend tests pass.
2026-06-25 — Spec accuracy audit: algorithm, locale fallback, pack distribution, locale addition (p18)
Type: Doc Fix
Summary:
- §2.5 AC locale fallback: Corrected — code falls back to Vietnamese (
copy_vi/name_vi), not English. Bothpack_loader.pyandprofile_resolver.pyuse_vias the hard fallback throughout. - §3 and §4.1: Removed references to the old planned “copy to
backend/apps/pulses/data/” step — backend reads directly from repo root. No copy step was ever built. - §5.1 core_prompts: Added missing
purposefield to example JSON (actualcore_prompt_list()returns{prompt_id, copy, purpose}). - §11.1 diagram: Updated ASCII diagram to show actual flow (no copy step).
- §11.5 Adding a locale: Full rewrite — there is no
config/translations/<locale>/subdirectory and nosupported_localesarray inmanifest.json. All locale copy is embedded inline ascopy_{locale}/hint_chip_{locale}/name_{locale}fields in the main config files. - §12 Step 2: Added as-built note — custom roles with
base_role_idinherit base role’sdefault_prompt_idsprepended (not shown in pseudocode). - §12 Step 4: Added as-built note — 2-tier chip cap used in practice (role chips capped at
role_max, additional org chips fill remaining slots up to_GLOBAL_MAX_CHIPS=5), not a single.slice(0, profile.max_chips). - §12 Step 5: Added as-built note —
industries.jsonhas NOplaceholder_{locale}fields in any industry entry; placeholder always falls through to_GENERIC_PLACEHOLDER[locale]. Per-industry placeholder text is data-layer deferred. - GLOSSARY.md: Corrected
daily_update_packentry — removed the stale “copied at build time” claim.
2026-06-25 — Spec accuracy audit: §2.6, deep backend/data-model gaps (p17)
Type: Doc Fix
Summary:
- §2.6 AC2:
maps_to_signal_idis stored inOrgCustomQuestionLibraryand returned via API, but NOT injected intoprofile_snapshot.extraction_context.signal_idsbyprofile_resolver.py. Extraction pipeline readssignal_idsfrom context; custom question signals are not wired through. Field is Phase 2 activation. Added as-built note to AC2 and §13 Not Yet Built. - See also p16 (below) for a comprehensive set of other accuracy fixes in this same audit pass.
2026-06-25 — Spec accuracy audit: §9 ES4 and audit doc corrected (p16)
Type: Doc Fix
Summary:
- §11.1: Corrected content loader path — no “copy into
backend/apps/pulses/data/” step exists;backend/apps/pulses/data/was never created. The backend reads directly from{repo_root}/daily_update_pack/via_pack_dir()(configurable viasettings.DAILY_UPDATE_PACK_DIR). - §11.2:
PulsePackLoaderpseudocode updated — replacedDATA_DIR = Path(__file__).parent / 'data' / ...(wrong) with actual_pack_dir()function usingsettings.BASE_DIR.parent / "daily_update_pack"as default. - §3:
pulse_v2i18n namespace was planned but never created. As-built: follow-up keys live inpulsenamespace; config keys inpulse_confignamespace. Nopulse_v2.jsonfile exists anywhere in the codebase. - §4.2
UserPulsePreference: Added missingpulse_role_idfield (the key field for chip resolution —views.py::pulse_profilereadsuser_pref.pulse_role_id). FixedMetaclass:OneToOneField(user)enforces uniqueness; nounique_togetherneeded. Addedrelated_name,blank=Trueon cache fields, anddb_table. - §11.3: Cache key format corrected — actual key includes
{locale}segment (pulse:profile:{user_id}:{org_id}:{pack_sha256}:{org_config_hash}:{locale}). Spec had omitted the locale segment. - §11.3: “Invalidated by
post_savesignal” was wrong — invalidation is done inline in the write views (no signals), callingcache.delete_patternafter each POST/PATCH/DELETE on the three config models. - §9 ES3: Spec said “logged at WARNING” for unknown role_id.
profile_resolver.pyhas no logger; fallback is silent. Added as-built note. - §9 ES4: Spec claimed “admin is shown a warning in
/settings/pulse-configthat the configured industry is not recognized.” Not implemented. TheIndustrySelectoris a<Select>dropdown of known industries — a stale configured ID renders as a blank selection, no warning banner. Added as-built note; the warning is deferred. - §9 ES8: Spec said “logged at WARNING” when materiality threshold is 0. No such log exists; the 0→null treatment is silent (via Python falsy check). Added as-built note.
- §5.1: Added three missing fields to the profile response example: top-level
"industry_name"(fromresolve_profilereturn dict) and"signal_severity_overrides"+"custom_vocab"inside"extraction_context". - §5.3: Corrected
follow_up_questionsitem shape: key is"question_text", not"question". Matches_generate_follow_up_questionsreturn value andAdaptiveFollowUp.tsxFollowUpQuestion.question_text. - UI audit doc (
docs/ui-ux/F83-daily-pulse-v2.md): CorrectedAdaptiveFollowUpsection: “one at a time viacurrentIdx” was wrong. The component renders up to 2 pending questions simultaneously usingpending.filter(...)— there is nocurrentIdxstate variable. Updated description to match actual code.
2026-06-25 — Spec accuracy audit: §6 UI/UX descriptions corrected vs. actual implementation (p15)
Type: Doc Fix
Summary:
- §6.2: Chips are not collapsible — they are always visible when the resolved profile returns any chips. Clicking inserts
chip_label:at cursor;titleattribute holds the full copy. Original text said “collapsible, expand to show full question text when tapped.” - §6.3: Config page has 4 sections as built (Industry, Role packs, Extraction settings, Company question library), not the designed 5. The “Custom roles” creation section is not wired in the frontend UI — the backend API (
POST /api/pulses/config/roles/) supports it but the config page doesn’t expose it. §13 updated with this deferred item. §2.6 acceptance criterion gets an as-built note (met via API). - §6.4: Chips wrap to the next line (
flex flex-wrap gap-2), not horizontal scroll. Original text said “chips scroll horizontally below the textarea.” - UI audit doc: Corrected “No drag-to-reorder implemented” — drag-to-reorder IS implemented for added chips in
RolePackEditorusing@hello-pangea/dnd(DragDropContext/Droppable/DraggablewithGripVerticalhandle). This was in the original fe-p4 implementation. UX score rationale updated to reflect the correction; UX score remains 8 (success animation, no-role notice, custom roles UI still pending). - UI audit doc: Corrected “chip row scrolls on narrow viewports” → “chip row wraps to next line (flex flex-wrap)“.
2026-06-25 — §11.3 cache invalidation gap: custom role/question mutations now invalidate profiles (p14)
Type: Bug Fix
Summary:
POST/PATCH/DELETE /api/pulses/config/roles/andPOST/PATCH/DELETE /api/pulses/config/questions/were not callingcache.delete_pattern(f"pulse:profile:*:{org_id}:*")after mutating custom role packs or custom questions. Spec §11.3 requires invalidation on all three config models; the implementation only coveredOrgPulseConfigurationPATCH. Result: a member could see stale hint chips for up to 1 hour after an admin added/changed a custom role or question.- Fixed: added
cache.delete_pattern(...)after every write operation inorg_custom_roles_list(POST),org_custom_role_detail(PATCH, DELETE),org_custom_questions_list(POST), andorg_custom_question_detail(PATCH, DELETE). - Added tests:
TestOrgCustomRolePacks::test_create_invalidates_profile_cacheandTestOrgCustomQuestions::test_create_invalidates_profile_cacheintest_pulse_v2_p3.py. Tests guard withhasattr(cache, "delete_pattern")so they pass under LocMemCache (which has no pattern delete) and assert under redis-py-based caches. - §4.3: corrected
follow_up_qacomment — actual keys arequestion_id/answer/answered_at, notquestion/answer/asked_at. - §5.1: corrected
max_visible_chipsexample value from 4 to 5 (global cap from resolution_rules.json; field_technician’smax_suggested_prompts: 4is used internally, not surfaced in the response). - §6.5: corrected
followup_*key names (underscore-free); updated vi copy to match actual locale files; movedconfig_*keys to their correctpulse_confignamespace note. - §6.6: corrected follow-up block implementation — uses
div+ Lumi design tokens, not<Card>withborder-primary/40. - Backend test suite: 4278 passed.
2026-06-25 — Implement §2.4 “never repeated within 2 days” follow-up dedup (p13)
Type: Feature Fix
Summary:
- §2.4 acceptance criterion [x] “Given follow-up was already asked for the same gap within 2 days, it is not asked again” was checked but not implemented.
_generate_follow_up_questionsreturned the same question on consecutive days. - Fixed: added
already_asked_ids: set[str] | Noneparameter to_generate_follow_up_questions.submit_pulsenow queries the last 2 days’ pulse entries for the user, collects theirfollow_up_qa[*].question_id, and passes the set asalready_asked_ids. Questions in that set are skipped. - Added test:
test_pulse_v2_p4.py::TestFollowUpQuestionGeneration::test_question_not_repeated_if_asked_within_2_days. - Backend test suite: 4276 passed (30 new tests since the last full run).
2026-06-25 — §5.3 and §5.5 API contract corrected for follow-up flow (p12)
Type: Doc Fix
Summary:
- §5.3 described
follow_up_answersin the submit body as being processed (it is not — field is accepted by serializer but ignored insubmit_pulseview; the design shifted during implementation). Updated §5.3 request body example and added an as-built note. - Added missing §5.5 documenting
POST /api/pulses/<id>/follow-up/— the endpoint that actually handles follow-up answer storage. This endpoint was in §13 but missing from the §5 API contract. - Renumbered: original §5.4 (pack content endpoints) unchanged; §5.5 is new.
2026-06-25 — §7.1 backend test table updated with accurate file references (p11)
Type: Doc Fix
Summary:
- All 20 §7.1 test cases previously referenced phantom file
tests/test_pulse_v2.py(which doesn’t exist). Updated every row to actualtest_pulse_v2_p1–p5.py+test_field_service_ontology.pyclass/method names. - Case #11 (“Submit with
follow_up_answersmerges into extraction context”): struck through. As-built design shifted:follow_up_answersis accepted byPulseSubmitSerializerbut not read in the view; follow-up answers are stored idempotently via the separatePOST /api/pulses/<id>/follow-up/endpoint. Covered byTestPulseFollowUp::test_follow_up_answer_storedin P4. - Case #18 (“Materiality threshold suppresses financial risk”): behavior IS implemented (
apps/ai/field_service.pylines 189, 226–229) and IS tested, but intest_field_service_ontology.py::TestMateriality, nottest_pulse_v2.py. Updated reference.
2026-06-25 — §7.2 E2E table reconciled with actual test file (p10)
Type: Doc Fix
Summary:
- §7.2 listed 7 cases but
daily-pulse-v2.spec.tshas 8 active tests. - Original cases #4 (“sets industry, saves”) and #5 (“admin disables a question”) diverged from implementation: #4 actual test only asserts page loads without error; #5 was never built (cross-session fixture complexity, deferred).
- Two cases present in the file (#8 industry selector visible, #9 built-in roles list visible) were not in the spec table.
- Updated §7.2: added note + as-built column; struck through case #5 with reason; added cases #8 and #9; updated case #4 description to match actual assertion.
- Updated §13 E2E row to reference “cases 1–4, 6–9; case 5 deferred”.
2026-06-25 — §13 doc fix: add missing P5 test file reference
Type: Doc Fix
Summary:
backend/tests/test_pulse_v2_p5.pyexisted and covered 10 cases forGET /api/pulses/pack/industries/andGET /api/pulses/pack/roles/(admin access, response shape, member/unauth forbidden, cross-tenant isolation) but was not referenced in §13 Implementation Audit.- Added row: “Backend tests: P5 — industries + roles pack endpoints” to §13.
- Updated
docs/ui-ux/F83-daily-pulse-v2.mdQA/QC table from “P1–P4, 20+ cases” to “P1–P5, 30+ cases”.
2026-06-25 — RolePackEditor add-question select migrated to shadcn (p8)
Type: Bug Fix / Polish
Summary:
RolePackEditor.tsx: last native<select>in any F83 UI file was the “add question” dropdown. Migrated to shadcn<Select>+<SelectContent>+<SelectItem>.handleAddQuestionsignature changed fromChangeEvent<HTMLSelectElement>tostring(RadixonValueChangeAPI). No tests broken; 10731 passed, 0 failed.- Audit doc
docs/ui-ux/F83-daily-pulse-v2.mdupdated: TASTE shadcn/ui row notes p8; RolePackEditor section notes fix; extraction settings<input>clarified as not a Rule 4 violation (design tokens, not stock palette). UI score rationale updated — no score change (9 remains, held by plain admin-only guard state).
2026-06-25 — Fix PulseConfigPage unit test broken by shadcn Select migration
Type: Bug Fix
Summary:
PulseConfigPage.test.tsx'shows industry selector with options'was checking for option text directly in the DOM — works with native<select>/<option>(always mounted) but fails with shadcn<Select>(Radix), which portal-mounts<SelectContent>only when the trigger is clicked.- Fixed by clicking the
comboboxrole element before awaiting option text. AddedfireEventimport. No production code changed. - Frontend test suite: 10731 passed, 0 failed across all 155 files.
2026-06-25 — Security fix + shadcn Select migration (p7)
Type: Bug Fix / Polish
Summary:
pack_questionsandpack_signalsendpoints now require admin role per spec §5.4. Previously accessible to all org members (read-only static catalog, no PII, but inconsistent with spec). Fixed by adding_require_adminguard matchingpack_roles/pack_industries. Testtest_pack_content_accessible_to_membersrenamed totest_pack_content_admin_only_for_membersand updated to assert 403 for all four pack content endpoints.IndustrySelectorinPulseConfigPage.tsxmigrated from native<select>to shadcn<Select>+<SelectContent>+<SelectItem>. (A second native<select>inRolePackEditorwas found and fixed in p8.)- POLISH scores updated: UI 8→9 (shadcn Select); Security 9→10 (spec §5.4 gap closed).
2026-06-25 — §13 audit consolidated; openpyxl dependency installed
Type: Maintenance
Summary:
- §13 Implementation Audit section headers updated: stale “Backend additions — IN REVIEW” and “Frontend — IN REVIEW” sections (referencing PRs #566, #568, #579, #594 — all merged) collapsed into single “Backend — SHIPPED (main)” and “Frontend — SHIPPED (main)” tables. No code changes; doc-only.
- Installed
openpyxl>=3.1,<4.0(already inpyproject.toml) into the backend venv. F87’s spreadsheet-parse tests (test_uploads.py) were failing withModuleNotFoundError: No module named 'openpyxl'. Backend test suite now: 4231 passed, 0 failed, coverage 97.23%.
2026-06-25 — E2E tests verified passing; route registration bug fixed
Type: Bug Fix
Summary:
/settings/pulse-configwas missing from the TanStack RouterrouteTree.ts. The catch-all route (path: '$') silently redirected all unknown paths to/work/today, causing E2E tests 7 & 8 to fail. AddedpulseConfigRouteto the route tree.- LoginPage
FormLabelhad explicithtmlForprops that overrode shadcn’s auto-generatedformItemId, breakingpage.getByLabel()in Playwright. Removed the overrides. - Added
data-testid="hint-chips"toHintChipscomponent for stable locator scoping. - Fixed
IndustrySelectormissinghtmlFor/idconnection (label ↔ select). - Added
UserPulsePreferenceseed entries forops@demo.localandceo@demo.local. - Added
hint_chip_vi/hint_chip_ento 5question_catalog.jsonentries (field technician role). - All 8 F83 E2E tests now pass in Chromium (24s). MVP_SCOPE.md E2E column flipped
🚧 → ✅.
2026-06-24 — Implementation shipped (backend) + in-review (frontend)
Type: Implementation
Summary:
- Backend fully shipped to main (PRs: F83-p1, p2, p3; FE-P2 backend additions).
- Frontend in review: PR #566 (PulsePage + HintChips + AdaptiveFollowUp + hooks + E2E) and PR #568 (PulseConfigPage + extraction settings + 62-locale propagation).
- Spec §7.3 updated:
resolveProfile()andPulseProfileProviderclarified as backend-only / hook-replaced — coverage confirmed via existing tests. - Diagrams updated: DIAGRAMS.md §4 profile resolution subgraph, backend/DIAGRAMS.md ER additions, GLOSSARY.md 6 new entries, F07 forward reference.
- MVP_SCOPE.md: F83 status
🔲→🚧, Backend🔲→✅, Frontend/E2E🔲→🚧. - POLISH.md: F83 placeholder row added.
2026-06-24 — Spec created
Type: Feature
Summary:
- New spec F83 covering Daily Pulse v2 — role-aware prompt packs, industry-aware extraction, org configuration, company question library.
Reason:
- F07 hardcoded six Vietnamese questions produce uniform noise across all roles and industries. The Brief starves on signal quality. F83 fixes the extraction contract at the prompt layer.
Impact:
- New DB models:
OrgPulseConfiguration,OrgCustomRolePack,OrgCustomQuestionLibrary,UserPulsePreference. - New API endpoints under
/api/pulses/profile/,/api/pulses/config/,/api/pulses/config/roles/,/api/pulses/config/questions/, and/api/pulses/preferences/me/. PulseEntrygetsprofile_snapshot,follow_up_qa,resolved_localefields.extract_pulse_entrymust consume resolved profile signals and entities.daily_update_packJSON files are copied intobackend/apps/pulses/data/at build time.
Tests:
- New
backend/tests/test_pulse_v2_p1.py(pack loader + models),test_pulse_v2_p2.py(profile resolver + endpoints),test_pulse_v2_p3.py(org config API + extraction) with combined 20+ backend test cases. - New
frontend/tests/e2e/daily-pulse-v2.spec.tswith 7 E2E cases. - New frontend unit tests in
src/test/.
2026-07-18 — Pulse hint labels fail closed instead of rendering blank controls
Type: P1 mobile bug fix
Summary:
- The canonical question catalog contains localized full question copy, but many
entries omit the optional short
hint_chip_*fields. The profile resolver now falls back to the localized question copy for built-in and custom questions. - Shared KMP state filters any still-blank server label before composition, so a malformed or stale response cannot become an empty tappable chip.
- Critical Maestro flow
81_android_pulse_hint_labels.yamlverifies that the Android Pulse form has no blank chip node and that any rendered hint row contains a nonblank hint node. Deployed API and named-device proof remain open.
2026-07-20 — Direct mobile submission invalidates the Pulse lane
Type: P1 mobile state-coherence fix
Summary:
- The shared Daily Pulse form and Pulse lane are independent screen models. Offline queue drain emitted a lane-refresh signal, but a normal online submit only updated the form. Returning to the lane could therefore show
Not submittedand another Submit Pulse action after the server had accepted the check-in. PulseChangeBusnow represents the real domain event rather than one transport path. Direct submit and successful offline drain both emit one payload-free event; an active lane reloads through its existing tenant-scoped repository.- Critical flow
50_pulse_form_submit.yamlnow proves both user contracts without test-only gestures: the pinned CTA remains reachable while the iOS keyboard is open, and returning from submission exposespulse_view_checkinplus the exact submitted badge without manual pull-to-refresh.
Proof:
PulseScreenModelTest.onlineSubmitNotifiesIndependentlyHostedPulseLanePulseScreenModelQueueTest.partialQueueSuccessNotifiesLaneWhileRemainingBacklogStaysQueuedPulseHomeScreenModelTest.laneModelRefreshesWhenPulseChangeBusEmitsWithoutRestart- Postmortem:
docs/postmortems/2026-07-20-pulse-submit-left-lane-stale.md
Evidence boundary:
- Shared JVM behavior is verified. On current-source iOS build 151, SHA-256
cdbcec61176f8c90b9b709df0bce83a28590f9e2d7e89f700ba79632ef6159d1, the fresh-admin artifact/Users/james/.maestro/tests/2026-07-20_211652executed form entry, keyboard-open submit, submitted readback, and captured the same-session team summary as submitted; it stopped because the final CTA was below the viewport. The corrected repeat/revisit journey passed completely at/Users/james/.maestro/tests/2026-07-20_213404. A single green disposable-fresh run, Android runtime, and named-device evidence remain separate Contract Graph gates.