# APAC Tenants Scoring Task

Handler: `scoring_function.lambda_handler`

This SQS-triggered Lambda recalculates a tenant's commercial signal score and
updates the allowlisted tenant bucket selected by the Worker:

- `active` -> `apac_active_tenants`;
- `sales_owned` -> `apac_sales_owned_tenants`;
- that row's `total_score` and `priority`;
- the tenant's current CST-day row in `apac_tenant_score_snapshots`;
- for Active tenants only, the existing HubSpot Contact whose primary email
  matches the tenant email.

The implementation in [`scoring_function.py`](scoring_function.py) is the
source of truth for the model described below.

## Inputs

Scoring reloads the current tenant row from the selected table. The model
uses these fields:

| Field | Use |
| --- | --- |
| `created_at` | 24-hour and 48-hour urgency and priority gates |
| `plan` | POC urgency and priority gates |
| `has_credit_card` | Activity points and no-card organization gates |
| `cluster_count` | Activity points and organization priority gates |
| `tenant_avg_qps` | Activity, urgency, and priority gates |
| `email_domain` | Free-domain detection |
| `entity_ai_category` | Fit score and organization/AI-company gates |
| `email` | Exact HubSpot Contact lookup for priority writeback |

`NULL`, invalid, or unavailable QPS is treated as numeric zero by Scoring. An
invalid or missing `created_at` does not satisfy either tenant-age gate.

The handler requires `tenant_id` and accepts `tenant_bucket`. A HubSpot target
message may also carry `priority_override = P0` with
`priority_reason = hubspot_target_account`. Scoring always computes and stores
the normal numeric score, then applies this override only to final priority.
Other bucket and override values are rejected.

## Score composition

```text
total_score = activity_score + fit_score + urgency_score
```

| Component | Maximum |
| --- | ---: |
| Activity | 40 |
| Fit | 30 |
| Urgency | 30 |
| Total | 100 |

### Activity score

Activity signals are additive and the result is capped at 40.

| Signal | Points |
| --- | ---: |
| `tenant_avg_qps >= 1` | +25 |
| `0.001 <= tenant_avg_qps < 1` | +12 |
| `cluster_count >= 1` | +10 |
| `cluster_count >= 3` | +8 additional |
| `cluster_count >= 10` | +5 additional |
| Has a credit card | +10 |
| `tenant_avg_qps >= 1` and no credit card | +5 |

Cluster thresholds are cumulative: 1, 3, and 10 or more clusters contribute
10, 18, and 23 points respectively before the overall Activity cap.

### Fit score

Fit selects exactly one row from this table; it is not additive.

| `entity_ai_category` and domain condition | Points |
| --- | ---: |
| `company_ai_core` | 30 |
| `company_ai_related` | 20 |
| `company_non_ai` with a non-free email domain | 15 |
| `company_non_ai` with a recognized free email domain | 5 |
| `institution_ai_related` | 15 |
| `institution_non_ai` | 8 |
| `individual` | 0 |
| Unknown or any other unmatched case | 3 |

An empty domain is neither an enterprise domain nor a recognized free domain.
Therefore, `company_non_ai` with an empty domain falls through to 3 points.
Free-domain detection uses the exact `FREE_EMAIL_DOMAINS` set in the code,
including common Gmail, Outlook, Yahoo, iCloud, Proton, QQ, 163, and 126
domains.

### Urgency score

Urgency signals are additive and the result is capped at 30.

| Signal | Points |
| --- | ---: |
| Created within 24 hours and `company_ai_core` | +20 |
| Created within 48 hours and `company_ai_related` | +15 |
| `plan = 'poc'` | +15 |
| `tenant_avg_qps >= 50` | +15 |
| At least 3 clusters, no credit card, and a known organization | +12 |

The age checks use `created_at` for every tenant. They do not check
`source_type`, so this behavior remains aligned with the current EMEA model.
`error_message` is not a scoring input and does not add urgency or force a
priority.

## Category gates

The following categories are treated as known organizations:

- `company_ai_core`;
- `company_ai_related`;
- `company_non_ai`;
- `institution_ai_related`;
- `institution_non_ai`.

Only `company_ai_core` and `company_ai_related` are treated as AI companies.
`individual`, `unknown`, empty, and unrecognized categories do not satisfy
organization-only priority gates.

## Priority calculation

Priority is not determined by total score alone. Scoring first evaluates all
P0 conditions. If none match, it evaluates the P1 conditions; otherwise the
tenant is P2.

### P0

A tenant is P0 when any of these conditions is true:

- `cluster_count >= 50`, regardless of tenant category;
- `tenant_avg_qps >= 50`;
- `tenant_avg_qps >= 10` and category is `company_ai_core`;
- created within 24 hours and category is `company_ai_core`;
- `plan = 'poc'` and category is either AI-company category;
- `total_score >= 75` and category is a known organization.

### P1

When no P0 condition matches, a tenant is P1 when any of these conditions is
true:

- `total_score >= 45`;
- created within 48 hours and category is `company_ai_related`;
- created within 24 hours, category is `company_non_ai`, and the email domain
  is non-free;
- `cluster_count >= 10` and category is a known organization;
- `cluster_count >= 3`, there is no credit card, and category is a known
  organization.

### P2

A tenant is P2 when it matches neither the P0 nor the P1 rules.

## Large-cluster behavior

Worker always retrieves the complete DevOps cluster list, including exact
service-plan counts. At `cluster_count >= 10`, Worker skips every per-cluster
Clinic QPS request and stores `tenant_avg_qps = NULL`.

Scoring consequently grants the cumulative 23 cluster Activity points but no
QPS points. At 50 or more clusters, every tenant is P0 regardless of category.
From 10 through 49 clusters, a known organization is at least P1 through the
explicit 10-cluster priority rule, while `individual` and `unknown` are not
promoted by cluster count alone.

## Examples

| Tenant signals | Score result | Priority result |
| --- | --- | --- |
| New `company_ai_core` within 24 hours, no other signals | Fit 30 + Urgency 20 = 50 | P0 through the new-core override |
| Enterprise-domain `company_non_ai`, 10 clusters, no card, unknown QPS | Activity 23 + Fit 15 + Urgency 12 = 50 | P1 by score and organization cluster gates |
| `institution_non_ai`, 10 clusters, has card, unknown QPS | Activity 33 + Fit 8 = 41 | P1 through the 10-cluster organization gate |
| `individual`, 10 clusters, no card, unknown QPS | Activity 23 + Fit 0 = 23 | P2; cluster count alone does not promote an individual |
| `individual`, 50 clusters, no card, unknown QPS | Activity 23 + Fit 0 = 23 | P0 through the 50-cluster override |
| `unknown`, no clusters, no card, unknown QPS | Fit 3 = 3 | P2 |

## Message and transaction behavior

A Worker message has this shape:

```json
{
  "tenant_id": "1372813089209295124",
  "source_type": "active_candidate",
  "event_type": "active_processed",
  "tenant_bucket": "active",
  "priority_override": "P0",
  "priority_reason": "hubspot_target_account",
  "hubspot_company_id": "123456789",
  "hubspot_match_method": "portal_id",
  "processed_at": "2026-05-25T02:00:00Z"
}
```

If the tenant no longer exists, the record is logged and skipped. Otherwise,
the tenant update and CST-day snapshot upsert commit in one transaction. A
processing exception rolls back the transaction and is raised to Lambda so
the SQS message is retried. The deployed event-source mapping uses batch size
1.

After that database transaction commits, Scoring normalizes the tenant email
and, for the Active bucket only, updates the existing HubSpot Contact identified
by that exact email. Sales-owned tenants retain their local score and snapshot
but log `sales_owned_skipped` without calling HubSpot. The request is a partial Contact update and only sets
`apac_pql_priority_tier`; it never creates a Contact. Outcomes are handled as
follows:

- success is logged as `updated`;
- HubSpot `404` is logged as `contact_not_found` and does not fail scoring;
- an empty or invalid tenant email is logged as `invalid_email` and skipped;
- a missing writeback token logs `disabled` and leaves local scoring enabled;
- `429`, `5xx`, and network failures use bounded retries, then fail the SQS
  record so its normal queue retry and DLQ policy applies;
- other `4xx` responses fail immediately.

Logs contain tenant ID, bucket, priority, and outcome, but never the tenant
email, HubSpot token, or HubSpot response body. The writeback is idempotent: a
retried message recalculates the same score and sets the same Contact property.

## Configuration

Required:

- `TARGET_DATABASE_URL`, or the equivalent `TARGET_DB_*` variables.

Optional:

- `TARGET_TIDB_SSL_CA_PATH` or `TIDB_SSL_CA_PATH`;
- `HUBSPOT_WRITEBACK_TOKEN` (empty disables Contact writeback);
- `HUBSPOT_WRITEBACK_PROPERTY_NAME` (default
  `apac_pql_priority_tier`);
- `HUBSPOT_WRITEBACK_TIMEOUT_SECONDS` (default `30`);
- `HUBSPOT_WRITEBACK_RETRY_COUNT` (default `3`);

The Lambda timeout is 180 seconds. A HubSpot `Retry-After` delay is capped at
8 seconds so the configured 30-second request timeout and three retries remain
within the invocation budget.
