Outlook People harvest — dual architecture (LCDL)

Consumer mirror: forge-cockpit-web org-people-dual-architecture.md.

Updated

LCDL owns Playwright read atoms on the OWA People host page (outlook.office.com/people/); Cockpit owns SQLCipher, canonical disk, ingest queue, caps, and contact linking; Edge owns operator session on an existing People tab.

Sole org-directory surface: The legacy Org Explorer (Delve iframe) crawl has been removed. Outlook People — and specifically its Organization tab — is now the only OWA org-structure source. harvest_source=outlook_people.

No Delve iframe / no EML: Host-page DOM + directory suggest API only.

Stable state boundary

LCDL does not write files/org/, maintain org_ingest_queue, stamp profile_check, or resolve pending_mail:* to canonical GUIDs. Cockpit consumer performs mail→graph_id lookup and canonical folder selection after OrgHarvestWire.from_task_result.

Suggestion disambiguation (identity = graph_id)

People search suggestion selection is centralized in the pure helper choose_person_suggestion(bodies, *, display_name, mail, graph_id) (consumed by OutlookPeopleReader._choose_suggestion). Resolution order is (1) exact graph_id, (2) exact mail, (3) a single loose display-name match. When a name query matches multiple distinct identities and neither graph_id nor mail pins one, the helper returns ok=False with reason suggest_ambiguous_no_graph_id_match — it never guesses. focus_person_via_search surfaces that reason as its error, so the Cockpit consumer keeps its identity guarantee (no contamination) without LCDL knowing canonical-disk policy. No safe match at all returns suggest_no_match.

Guided ingest prefetch: guided_focus_read_and_dismiss may open LPC from captured suggest API bodies before Enter (search_path: prefetch); Cockpit validators skip the one-Enter contract for that path only.

Org-chart edges via the Organization tab. LCDL's home_snapshot wire carries empty edges[]. The richer path is the guided org item: LCDL parses the LPC Organization tab (manager chain, direct reports, works-with) into name-bearing fields on the profile card; the Cockpit consumer (persist_org_structure_from_card) resolves those names to graph_id and writes org_edge rows. LCDL still emits names only — it never resolves identity or writes the graph. With Org Explorer removed, the Organization tab is the sole source for org structure including net-new discovery; People resolves edges by display-name match on the consumer side.

Governed tasks (v1)

task_id Purpose
outlook_probe_people_health /people/ URL, search combobox, left tree ready
outlook_people_home_snapshot Visible contact rows (+ scroll); operator row from tree mail
outlook_people_focus_search Directory search → suggest API → option click
outlook_people_focus_tree Click [role=row] or [role=treeitem] by name/mail
outlook_people_snapshot_person Tree/search focus + profile panel parse
outlook_people_read_profile_panel Parse flyout / detail panel text
outlook_people_dismiss_profile_panel Escape dismiss

Input: { "session_ref": "…", "operator_graph_id": "…", "operator_mail": "…" } on a CdpOwaPageShim for a People tab.

Connector: src/forge_lcdl/playwright/connectors/outlook_people.py (OutlookPeopleReader).

End-to-end (LCDL slice)

session_ref → PlaywrightPageSession (People host tab)
  → probe_health (search + tree)
  → harvest_home_snapshot ([role=row] parse, optional scroll)
  → wire JSON: people[], edges[] (empty v1), focused_graph_id, upn
  → optional: focus_search / focus_tree → snapshot_person → read_profile_panel

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/outlook_read_v1.py runners lcdl_outlook_bridge.run_task
Host-page selectors / evaluate outlook_people.py Must not add page.locator in people_harvest.py People UI
CDP tab pick pick_outlook_people_cdp_page_target _attach_outlook_people_tab Existing People tab
Directory suggest capture Network listener on POST …/search/api/v1/suggestions?domain=People Signed-in session
Row / panel parse parse_people_row_text, reuses parse_profile_card_text from owa_lpc_reader
pending_mail:{email} wire ids resolve_wire_graph_id _resolve_graph_ids_from_db
Wire shape people[], focused_graph_id OrgHarvestWire.from_task_result
SQLCipher, caps, source tag people_harvest.py, ingest.py
Org structural edges (home wire) — (empty edges[]) Organization tab parse for net-new discovery
Org structural edges (guided org item) Organization tab parse → manager / DR / works-with names on the card persist_org_structure_from_card resolves names → org_edge (section_reports_to, section_manager_peers, works_with); surfaces edges_linked / org_edges_name_only People Organization sub-view
Read-only guarantee READONLY_POLICY.md, read-only clicks _ORG_CDP_LOCK Operator obeys policy

What LCDL must not do

Forbidden in LCDL Belongs in consumer
org_people.sqlite, org_graph.sqlite Cockpit SQLCipher
files/org/ paths, harvest_manifest.json Cockpit org/ingest.py
COCKPIT_ORG_PEOPLE_MAX_ROWS cap people_harvest.py
Mail → canonical graph_id merge people_harvest._resolve_graph_ids_from_db
Depth / guided scope policy Cockpit people_harvest.py + depth_policy.py

Data sources (priority)

  1. Directory suggest APIPOST /search/api/v1/suggestions?domain=People (DisplayName, EmailAddresses, JobTitle, Department, OfficeLocation)
  2. Contact list rows[role=row] innerText (name, mail, phone)
  3. Left tree — operator mail from [role=tree"] [role=treeitem"]
  4. Profile panel — flyout text via shared owa_lpc_reader parsers (parse_contact_panel_fields, parse_profile_card_text)

Not in v1: Delve GraphQL, personaphoto?aadObjectId=, org hierarchy edges in the home-snapshot wire (the guided org item still yields hierarchy via the Organization tab → consumer org_edge).

Wire shape (summary)

{
  "ok": true,
  "focused_graph_id": "5af741ae-…",
  "upn": "lzvyahin@softserveinc.com",
  "people": [{
    "graph_id": "pending_mail:user@example.com",
    "display_name": "User Name",
    "mail": "user@example.com",
    "job_title": "…"
  }],
  "edges": []
}

When Azure AD GUID is known (operator env or prior harvest), graph_id is a lowercase GUID.

People task surface

LCDL capability People task
Hierarchy snapshot outlook_people_home_snapshot (flat list; structure from Organization tab)
Search focus outlook_people_focus_search (directory suggest)
Row click outlook_people_focus_tree
Profile panel outlook_people_read_profile_panel
Health outlook_probe_people_health

Outlook People is the only org-directory surface; the former Org Explorer task atoms were removed.

AutomationFlow examples (forge-cdp-manager)

Documented JSON v1 flows (orchestration loops stay in Cockpit people_ingest.py):

File Atoms
flows/examples/outlook/people-health-probe.lmeta outlook_probe_people_health
flows/examples/outlook/people-guided-one.lmeta probe → outlook_people_guided_ingest_one
flows/examples/outlook/people-home-snapshot.lmeta probe → outlook_people_home_snapshot

Canonical operator entry: Cockpit org-people-ingest.md.