Handbook
CRM portfolio harvest — dual architecture (LCDL)
Consumer mirror: forge-cockpit-web crm-portfolio-dual-architecture.md.
Updated
LCDL owns Playwright read atoms (Dynamics isolated-world evaluate, virtualized grid scroll, entityrecord navigation); Cockpit owns SQLite, smart-sync planner, audit changelog, on-disk account folders, and portfolio board; Edge owns operator session on *.crm*.dynamics.com with Accounts list or record pages open.
No unified_message / EML: CRM harvest is account list + form/subgrid JSON only.
Governed tasks (v1)
task_id |
Purpose |
|---|---|
crm_probe_portfolio_health |
Account grid visible, portfolio URL |
crm_probe_list_view |
Assert current view_name matches expected (guided multi-view) |
crm_list_portfolio_accounts_viewport |
Visible virtualized rows only |
crm_list_portfolio_accounts_scroll |
Scroll harvest with dedupe on row-id |
crm_harvest_account_record |
Navigate entityrecord URL; form + Contacts/Opportunities subgrids |
Input: { "session_ref": "…" } on a Dynamics tab. List scroll optional: { "max_scroll_rounds": 80 }. Record enrich: { "account_id": "…", "record_url_template": "…", "scope_depth": 2, "use_odata": true }.
Scripts: src/forge_lcdl/playwright/scripts/crm_dynamics.py, connector connectors/crm_dynamics.py.
End-to-end (LCDL slice)
session_ref resolved → PlaywrightPageSession (Dynamics tab)
→ evaluate_readonly(portfolio_health) # probe
→ evaluate_readonly(list_accounts_viewport|scroll) # list wire
→ wire JSON: accounts[], view_name, record_url_template
→ per account: navigate entityrecord URL
→ evaluate_readonly(harvest_account_form + subgrids)
→ wire JSON: description, contacts[], opportunities[]
Consumer calls via cockpit_server.lcdl_outlook_bridge.run_task — LCDL does not import Cockpit.
Responsibility matrix
| Concern | forge-lcdl | Cockpit consumer | Edge |
|---|---|---|---|
| Governed read tasks | tasks/crm_read_v1.py (and runners) |
lcdl_outlook_bridge.run_task |
— |
| Dynamics DOM / scroll JS | crm_dynamics.py |
Must not add page.locator in CRM harvest modules |
Sales Hub UI |
| CDP tab pick | pick_crm_portfolio_cdp_page_target |
_attach_crm_portfolio_tab, _attach_crm_tab |
Existing Dynamics tab |
| Tab visibility (background harvest) | apply_cdp_focus_emulation on selective CDP attach |
Same as messaging (EDGE-CDP-ATTACH.md) |
Virtualized .ag-body-viewport may show zero rows when hidden |
| Virtualized grid scroll | crm_list_portfolio_accounts_scroll |
Passes max_scroll_rounds |
.ag-body-viewport |
| Entityrecord navigation | URL pagetype=entityrecord&etn=account&id=… |
Supplies record_url_template from list wire |
— |
| OData on record page | Optional read via in-page GET responses | Normalizes in merge_opportunity_wire |
No token export |
| List wire | accounts[], account_id from row-id |
CrmHarvestWire.from_task_result |
— |
| Detail wire | contacts[], opportunities[], form fields |
ingest_account_detail |
— |
| Smart sync / audit | — | sync_plan.py, crm_audit.sqlite |
— |
| Portfolio swimlanes | — | portfolio_metrics.py, portfolio_board |
— |
| Read-only guarantee | Allow/deny table in CRM-READ-STRATEGIES | audit_read_only_ingestion |
Operator obeys policy |
What LCDL must not do
| Forbidden in LCDL | Belongs in consumer |
|---|---|
crm_portfolio.sqlite, crm_audit.sqlite |
Cockpit SQLite |
files/crm/accounts/ JSON artifacts |
Cockpit crm/ingest.py |
| Enrich tier planner, stale-days logic | Cockpit sync_plan.py |
COCKPIT_CRM_* caps and reconnect policy |
Cockpit harvest modules |
| Portfolio board aggregation | Cockpit queries.py |
| Offline metadata backfill | Cockpit remediate.py |
Read-only allow / deny
See CRM-READ-STRATEGIES.md for the full table. LCDL tasks are marked read_only: true; forbidden actions include Save, Assign, Delete, toolbar New/Share, and compose/send on related records.