# HubSpot Owner Directory Sync Bootstrap

## Problem Statement

PR #2573 shipped the binding-based owner-sync contract, but it deliberately did
not populate `HubSpotOwnerBinding__c`. That means inbound HubSpot records still
land on the Integration User unless someone manually seeds a `bound` binding
row ahead of time.

This phase adds the missing runtime/bootstrap layer for issue #2205. It
actively pulls the HubSpot owners directory through the now-available
`connectorSource` substrate and refreshes binding rows so inbound owner
translation can resolve a real local owner when the identity match is safe.

## Governing Invariant

`hubspot_owner_id` never maps directly to CRM `OwnerId`. `HubSpotOwnerBinding__c`
remains the only owner crosswalk. This phase writes only binding rows and
binding-exception rows. It does not write `OwnerId` onto any business object,
and it does not modify the existing inbound or outbound owner flows.

## Scope

This phase implements the four follow-up items called out in
`hubspot-owner-bidirectional-sync-implementation.md`:

1. use the `hubspot-crm-owners` connector profile
2. refresh `HubSpotOwnerBinding__c`
3. bootstrap `PERSON` bindings by the current runtime's case-insensitive email
   equality path when there is exactly one active local `User` match
4. record unresolved directory cases in `HubSpotOwnerBindingException__c`

The flow is a daily scheduled standard flow owned by the PingCAP HubSpot
companion pack. `connectorSource` pulls the external owner directory first; the
flow body then executes once per returned owner item.

## Flow Element Graph

1. `Start` pulls `owners` from `hubspot-crm-owners` through
   `connectorSource`.
2. `FindExistingBinding` looks up `HubSpotOwnerBinding__c` by HubSpot owner id.
3. `CheckManualLock` exits immediately when the existing binding row has
   `ManualLock__c = true`.
4. `CheckOwnerCandidate` classifies obvious non-bootstrap cases before any user
   lookup:
   - non-`PERSON` owner type
   - archived owner
   - blank email
5. `FindActiveUserByEmail` looks up an active local `User` by raw
   `$Record.email`.
6. `FindActiveUserByEmailDup` checks whether a second active `User` shares that
   same raw email value.
7. `UpsertBoundBinding` updates or creates a `bound` binding only when the
   match count is exactly one.
8. Failure branches (`unsupported`, `archived`, `unresolved`) update or create
   a non-bound binding row, then append a
   `HubSpotOwnerBindingException__c` record that captures the reason.

## Email Match Rule

The current runtime branch available for verification supports `LOWER(...)` in
flow formulas but does not support `TRIM(...)`. Because this phase cannot
reliably author a trim-capable formula today, it uses the documented fallback
path:

- store the raw `$Record.email` value in `HubSpotOwnerBinding__c.NormalizedEmail__c`
- look up `User.Email = $Record.email`
- rely on TiDB's case-insensitive collation for email equality

This means case-only differences still match, but leading or trailing
whitespace is not corrected in this phase. A future runtime upgrade can tighten
this to true lower+trim normalization without changing the binding invariant.

## Exactly-One-Active-Match Rule

Only `User` rows with `IsActive = true` participate in automatic binding.

The flow enforces "exactly one" in two steps:

1. primary lookup: `User.Email = $Record.email AND IsActive = true`
2. duplicate lookup: same filter plus `Id != firstMatch.Id`

Outcomes:

- exactly one active `User` match: write `BindingStatus__c = bound`,
  `CRMPrincipalType__c = User`, `CRMPrincipalId__c = <User.Id>`, and
  `SourceOfTruth__c = auto_email`
- zero active matches: write `BindingStatus__c = unresolved` and create a
  `MissingBinding` exception
- multiple active matches: write `BindingStatus__c = unresolved` and create an
  `AmbiguousBinding` exception

Inactive local users never satisfy the bootstrap rule.

## Binding Status Decisions

The flow uses binding status to record why an owner item was not auto-bound:

- `bound`: exactly one active local `User` match
- `unresolved`: blank email, no active match, or multiple active matches
- `archived`: HubSpot owner item is archived
- `unsupported`: HubSpot owner type is not `PERSON`

For non-bound outcomes the flow clears `CRMPrincipalType__c`,
`CRMPrincipalId__c`, and `SourceOfTruth__c` so the row cannot be mistaken for a
live crosswalk.

## Exception Write Behavior

This phase appends an exception row every time a directory item is classified as
non-bindable. The exception record stores the real operational fields used by
later reconciliation:

- `ExceptionType__c`
- `Status__c = open`
- `Message__c`
- `HubSpotOwnerId__c`
- `FirstSeenAt__c`
- `LastSeenAt__c`
- `RetryCount__c = 1`

When the flow updated or created a binding row first, the exception also links
back through `Binding__c`.

This phase does not deduplicate or reopen prior exceptions. Exception
aggregation, suppression, and conflict arbitration remain later operational
concerns.

## ManualLock Protection

`ManualLock__c` is a hard stop.

If the current `HubSpotOwnerBinding__c` row is manually locked, the flow does
not overwrite the row, does not clear fields, and does not emit a new exception
record for that owner. Manual operator intent wins over automated refresh.

## Out of Scope

This phase does not implement:

- `QUEUE` principal mapping
- direct business-record `OwnerId` writes
- inline owner-directory lookup inside inbound record-save flows
- conflict arbitration between manual/admin choices and automatic bootstrap
  beyond `ManualLock__c`
- exception deduplication or retry-rollup behavior
- outbound owner-flow changes
- new consent or capability declarations beyond the existing pack dependency on
  `hubspot-crm-connector`
