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.