# SuccessFactors → Helix Event-Driven Lifecycle Integration

**Audience:** SAP SuccessFactors administrator / iflow author (SAP Integration Suite — Cloud Integration, a.k.a. CPI) at the customer's HRIS team.
**Version:** Helix v0 — established 2026-06-02.

Helix's joiner / leaver lifecycle is event-driven: SuccessFactors pushes
`Employee Hire` / `Employee Termination` events through an outbound
integration; Helix **authenticates, records the receipt, and immediately
returns `200 {accepted: true, events_received: N}`**, then applies the
event on a background task (fast-ACK, since 2026-07-01 — see §6a). SF's
integration center therefore never blocks on the SuccessFactors OData
round-trips + downstream provisioning a single event triggers, which had
been overrunning the gateway proxy timeout and surfacing as `504 Gateway
Time-out` on the SF side. The historical full-sync path is retained as a
reconciliation backstop (it also reconciles any event a crashed background
apply dropped). To wire
this up, the HRIS team needs to do **four things** on the SF side and
hand Helix **one thing** (the shared HMAC secret). The Helix side is
already implemented; this document is the contract you need to satisfy.

---

## 1. Architecture (target state)

```
SAP SuccessFactors Employee Central
         │  publishes event
         ▼
Intelligent Services Center  (built-in to EC; standard activation)
         │  event subscription
         ▼
SAP Integration Suite / Cloud Integration (CPI)
   iflow does:
     1. Map SF event payload → Helix event schema (section 4 below)
     2. Stamp X-SF-Timestamp header (current epoch ms)
     3. Compute HMAC-SHA256 over `timestamp + "." + body` using the
        shared secret stored in CPI keystore
     4. POST to Helix webhook URL
         │
         ▼
POST https://helix.pingcap.net/api/v1/hr/successfactors/webhook/{slug}
  Headers:
    X-Helix-Webhook-Token: <shared secret>
    X-SF-Timestamp:        <unix epoch ms>
    X-SF-Signature:        sha256=<hex>
    Content-Type:          application/json
  Body: { "events": [ ... ] }    # schema in section 4
```

CPI is the SAP-recommended path for any outbound integration that needs
**dynamic header computation** (HMAC body signing). The earlier
Integration Center can map fields but cannot compute HMAC over a request
body; the previous-generation Cloud Application Event Hub is the
direct-fire alternative. Either works as long as the wire contract in
sections 3–6 is met.

**Don't have CPI / can't script HMAC?** Helix also accepts plain **HTTP
Basic auth** (see §2a) — which the Integration Center supports natively
from the "身份验证类型 / Authentication type" dropdown with zero scripting.
It's a lower security bar than HMAC (no per-request body binding), so it
should be paired with the source-IP allowlist (§3a). Pick **one** auth
scheme: Basic *or* HMAC. The endpoint accepts either.

---

## 2. What Helix needs from you (one item)

The HMAC shared secret. Helix generates a 256-bit cryptographic random
string per HRIS instance and hands it to you through a secure channel
(1Password / Vault). You install it in the CPI iflow's keystore.

**Do not** invent your own secret on the SF side — Helix's admin
console is the source of truth so we can rotate it without coordinating
the cutover with you.

**Generation flow** (admin console: `Admin → 集成 → HRIS instance →
"轮换 Webhook 密钥"`):

1. Helix admin clicks "Rotate webhook secret" → confirmation dialog
2. Backend generates a fresh 256-bit secret (`secrets.token_hex(32)`),
   moves the current one to a "previous" slot with a 24h expiry, and
   stores the encrypted blob
3. The new secret is shown in a modal **exactly once** — admin copies
   it out (after copying, the modal lets you close)
4. Admin hands the secret to HRIS via 1Password / Vault link
5. HRIS / CPI iflow keystore is updated; the iflow keeps running
   throughout
6. During the 24h overlap window the webhook endpoint accepts BOTH
   secrets — SF / CPI can pick up the new secret on its own deploy
   cadence without missing events
7. After 24h the previous secret automatically expires; webhooks
   signed with it return `401`

The plaintext of the rotated secret is **never** re-displayed via any
admin endpoint — if it's lost, just rotate again. Rotation auditing
goes to `audit_logs.action = 'integration.webhook_secret_rotate'`
(without the plaintext).

---

## 2a. Simpler option: HTTP Basic auth (no CPI, no scripting)

If computing an HMAC signature on the SF side is not practical (e.g. you
are wiring this up in the **Integration Center**, which cannot sign a
request body), use **HTTP Basic auth** instead. The flow:

1. A Helix admin clicks **「Webhook 认证」→「生成 Basic 凭证」** on the
   integration row (`Admin → 集成`). Helix generates a username
   (`sf-webhook`) + a strong random password and shows the password
   **once**. The admin hands it to you over 1Password / Vault.
2. In the SF REST destination, set **「身份验证类型 / Authentication
   type」= 基本验证 / Basic** and paste the username + password. That's
   it — no custom headers, no timestamp, no signature.
3. Helix validates `Authorization: Basic base64(user:password)` on every
   call. No `X-SF-Timestamp` / `X-SF-Signature` headers are needed on
   this path.

**Do NOT pick OAuth or certificate auth** — Helix uses neither. The only
two accepted schemes are Basic (this section) and HMAC (§3).

> **Security note.** Basic auth has no body binding or replay window, so
> a leaked password lets an attacker forge requests (bounded by the
> always-on mass-leaver brake, §3). **Strongly pair it with the IP
> allowlist (§3a)** so only SAP's egress IPs are accepted. Rotating the
> password (re-clicking「生成 Basic 凭证」) immediately invalidates the old
> one.

---

## 3. Wire contract — request envelope

| Header                  | Required | Value                                                                                          |
| ----------------------- | -------- | ---------------------------------------------------------------------------------------------- |
| `Content-Type`          | yes      | `application/json`                                                                             |
| `X-Helix-Webhook-Token` | yes      | The shared secret. Sent as plain bearer for the connection-level auth. Must match exactly.     |
| `X-SF-Timestamp`        | yes      | Unix epoch — seconds or milliseconds (Helix auto-detects; anything past year-5000s is treated as ms). Must be within ±5 minutes of Helix's clock or the request is rejected as a replay. |
| `X-SF-Signature`        | yes      | HMAC-SHA256 of `<X-SF-Timestamp>.<raw_body>` (literal dot, no whitespace) keyed with the shared secret. Hex-encoded. May optionally be prefixed `sha256=`. |

A request that fails ANY of token / timestamp window / signature returns
`401`. A request with a malformed body returns `400`.

### Why HMAC over the body (not just a token)

A token-only scheme means a leaked secret lets an attacker craft
arbitrary payloads (e.g. fabricate `offboard` events for every
employee). HMAC over `timestamp + "." + body` ties each signed request
to its exact bytes plus a 5-minute window — the same protection
Stripe / GitHub / Slack webhooks use. Required for production.

### Mass-leaver brake

Helix refuses any single webhook batch with more than
`max(25, 5% of active employees)` `offboard` events. This protects
against a corrupted SF batch (or a leaked secret + replayed body) from
sweep-terminating the entire org. If the brake fires the response is
`409 Conflict` with code `MASS_LEAVER_BRAKE`; SF should NOT retry —
the operator at Helix will manually review the staged events through
the sync console.

---

## 3a. Source-IP allowlist (recommended, esp. with Basic auth)

A Helix admin can pin an **IP allowlist** on the integration
(`Admin → 集成 →「Webhook 认证」→ IP 白名单`): one IP or CIDR per line.
When set, Helix rejects (`403 WEBHOOK_IP_NOT_ALLOWED`) any webhook whose
resolved source IP is outside the ranges — *before* any auth or body
processing. An **empty** allowlist means allow-all (auth alone gates the
endpoint).

This applies to **both** auth schemes but matters most for Basic: it
recovers most of the security HMAC would otherwise provide, since a
leaked Basic password is useless from an IP outside SAP's egress ranges.

**Action for you:** send the HRIS/BTP team's **outbound egress IP
ranges** (SAP publishes these per data center) to the Helix admin so the
allowlist can be populated. Until then it stays empty (allow-all).

---

## 4. Payload schema

```json
{
  "events": [
    {
      "event_type": "onboard",
      "user_id": "100501",
      "person_id_external": "10050",
      "effective_date": "2026-08-15",
      "emailName": "wei.zhang",
      "firstName": "Wei",
      "lastName": "Zhang",
      "displayName": "张伟 Wei Zhang",
      "personalEmail": "wei.zhang@gmail.com",
      "phoneNumber": "13812345678",
      "country": "CHN",
      "defaultLocale": "zh_CN",
      "empId": "100501"
    },
    {
      "event_type": "offboard",
      "user_id": "100123",
      "effective_date": "2026-06-30",
      "termination_reason": "D01"
    }
  ]
}
```

**Body shape is flexible (since 2026-06-24).** The canonical form is the
`{"events": [...]}` wrapper above, but the endpoint also accepts what a
zero-code connector naturally emits, and normalises it for you:

* a **bare JSON array** of event objects — `[ {…}, {…} ]` (SAP
  SuccessFactors **Integration Center** with a record-level field
  mapping POSTs exactly this — root fields `event_type` / `user_id` /
  `person_id_external` / `effective_date`, no `events` node needed);
* a **single event object** — `{ "event_type": …, "user_id": …, … }`;
* **NDJSON** — one JSON object per line.

So in Integration Center you can map the four fields at the record root
and POST directly — no need to build a nested `events` wrapper. Malformed
JSON still returns `400 WEBHOOK_BODY_INVALID`.

Field rules:

| Field                         | Required | Notes                                                                                  |
| ----------------------------- | -------- | -------------------------------------------------------------------------------------- |
| `events`                      | yes      | Array, max 500 per request. Larger batches must be split.                              |
| `events[].event_type`         | yes      | `"onboard"` or `"offboard"`. Other values are skipped (no error).                      |
| `events[].user_id`            | yes      | The SF `User.userId` (your tenant's internal employee ID).                             |
| `events[].effective_date`     | yes      | ISO-8601 date (`YYYY-MM-DD`). For `onboard` = hire date / start date; for `offboard` = **last working day (最后工作日)** — Helix writes it to the person's termination date and schedules account closure / access revocation for the first moment AFTER this day ends in the employee's local timezone. It MUST equal the `EmpEmployment.endDate` SF stores for the same termination (confirmed with HRIS 2026-07-30) — Helix's daily reconciliation sync reads `endDate` from OData directly, and a mismatch would see the two paths overwrite each other. Time component (if present) is dropped. |
| `events[].termination_reason` | no       | For `offboard`: the SF termination-reason **externalCode** (`EcEvenReason2` picklist, e.g. `D01` 主动离职) from `EmpEmploymentTermination.customString85`. Key may be `termination_reason`, `terminationReason`, or `eventReason`. When present Helix trusts it directly; when absent Helix resolves the reason from SF itself before acting. Reasons configured as "no account closure" (currently **D03 调往中国数据中心 / D04 兼岗结束 / D07 人员分类转换 / D08 调往德国数据中心**) skip offboarding entirely and are recorded in the lifecycle ledger as `offboard_skipped` — strongly prefer SENDING these events with the reason code over silently filtering them in the iflow, so both sides keep an auditable record of the non-action. |
| `events[].person_id_external` | no       | SF `PerPerson.personIdExternal` — the key for the **person**, as opposed to `user_id` which keys one **employment**. Recommended: it's what `PerPhone` / `PerEmail` / `PerPersonal` hang off, so Helix uses it to attach the hire's mobile, personal email, birthday and gender. Absent ⇒ Helix resolves it itself via `EmpEmployment` (one extra SF call). **⚠️ Must be the real person key, never the assignment's own `userId` echoed back.** Send it empty rather than self-referential: a value equal to `user_id` carries no information, and Helix treats it as absent and re-resolves from `EmpEmployment` (2026-08-25 — the outbound mapping sent `person_id_external: "80100063"` for a 兼岗 whose SF `EmpEmployment` row says `P01008`, which made the concurrent-employment guard below inoperative). |
| `events[].emailName`          | no       | **Optional desired mailbox name** for an `onboard`. When present it is the TOP-priority source for the corporate-email local part (above SF `User.email` and the given.family derivation). Accepts a bare local part (`zhang.wei`) or a full address (`zhang.wei@pingcap.com` — local part is taken). Absent ⇒ Helix derives it (see below). The JSON key may be `emailName` or `email_name`. |
| `events[].firstName`          | no\*     | Given name. Feeds the `given.family` email derivation and the person record. |
| `events[].lastName`           | no\*     | Family name. Feeds the email derivation and the person record. |
| `events[].displayName`        | no       | Full display name. If absent Helix derives it from `firstName` + `lastName`. |
| `events[].personalEmail`      | no\*     | The hire's **personal** email. Where the welcome mail (carrying the corporate mailbox + first password) is sent, and the Google account recovery/home address. Absent ⇒ no welcome mail can be delivered until the sync fills it. |
| `events[].phoneNumber`        | no       | The hire's mobile. Used for the Google account recovery phone (normalised to E.164 using `country`). Key may be `phoneNumber`, `mobilePhone`, or `mobile_phone`. |
| `events[].country`            | no       | ISO country of the hiring company (`CHN`, `USA`, …). Drives E.164 formatting of `phoneNumber` and seeds the person record. Key may be `country` or `countryOfCompany`. |
| `events[].defaultLocale`      | no       | Preferred language (`en_US`, `zh_CN`, `ja_JP`). Defaults the welcome-mail language. Key may be `defaultLocale`, `default_locale`, or `preferred_language`. |
| `events[].empId`              | no       | Employee number. Last-resort email local part; seeds the person record. Key may be `empId`, `employeeNo`, or `employee_no`. |

**\*** These fields are the ones that let Helix **provision the account
before the hire date**. The full SF `User` + `EmpJob` data sync only
runs on the hire date, so an event that arrives days earlier has nothing
to pull yet. When the event **carries** `firstName` / `lastName` /
`personalEmail` (and ideally `emailName` + `country`), Helix stores the
person and creates the corporate mailbox + Google account upfront — the
later sync just enriches (department, manager, position) and never
overwrites a value the sync itself finds. All carry fields are optional;
each one absent simply falls back to the SF pull once the sync runs.

You do NOT need to include department / manager / position — those are
always pulled by Helix from the SF OData API (using the same `username` /
`password` credentials the integration row already stores).

### How the corporate email is decided (`onboard`)

On an `onboard`, Helix picks the email **local part** in this order and
appends `01`, `02`, … only if that exact address is already taken:

1. **`emailName` from the event** (if you sent one) — your explicit choice.
2. SF `User.email` (if it's already a real corporate address).
3. `firstName` + `lastName` → `given.family` pinyin (e.g. 张三丰 → `sanfeng.zhang`).
4. `empId` (employee number) as a last resort.

The chosen address is **written back to SF `PerEmail`** (the contract in
§1 / the table at the top) the moment the event is received, so you see
it before the start date; the live Google mailbox itself is created on
the hire date.

### Event type mapping (SF → Helix)

| Standard SF event              | Helix `event_type` |
| ------------------------------ | ------------------ |
| Employee Hire                  | `onboard`          |
| Rehire                         | `onboard`          |
| Employee Termination           | `offboard`         |
| Concurrent Employment Hire     | `onboard` — see below |
| Job Information Change (only)  | *not yet wired*    |

### 兼岗 / Concurrent Employment Hire — no second account

A concurrent employment is **not a new hire**. SF gives the second
assignment its own `userId` under the *same* `personIdExternal`, and the
human already has a mailbox, a Google account and a Helix identity.
Helix must never mint a second set — so a `Concurrent Employment Hire`
push is registered as an **alias** of the existing person
(`identity_hris_user_aliases`) and creates nothing.

That decision rests on exactly one fact: **`personIdExternal != userId`**.
Please keep it usable:

* send `person_id_external` as the **real person key** (`P01008`), or
* leave it **empty** and let Helix resolve it from `EmpEmployment`.

Sending the assignment's own `userId` in that field (`80100063`) makes
the push indistinguishable from a brand-new hire. On 2026-08-25 that is
exactly what happened: a 兼岗 became a second person with a second
mailbox (`…01@pingcap.com`) and a real Google account.

Two things Helix deliberately does **not** use, because both were
falsified against the live CN + EU roster: `EmpEmployment.assignmentClass`
(`ST` on the main position *and* on every 兼岗 row), and
`EmpJob.event` / `eventReason` (only the current effective slice, so a
later Data Change overwrites the hire event — 兼岗 rows and a *canonical*
row both read `1725` / `G06`). If SF later exposes a real
「是否主岗」 / primary-assignment field in OData, tell us and we'll take it
as a second corroborating signal.

The mirror-image event, `D04 兼岗结束`, is already on the
no-account-closure list below — the human keeps working when a 兼岗 ends.

The Job Information Change event will land in a follow-up Helix release;
for now please filter those out at the iflow level.

### Terminations that must NOT close accounts (2026-07-30 alignment)

Four `EcEvenReason2` termination reasons are internal transitions where
the employee keeps working — only the SF employment slice ends:

| externalCode | Label            | What it is                          |
| ------------ | ---------------- | ----------------------------------- |
| `D03`        | 调往中国数据中心 | cross-tenant move to the CN SF DC   |
| `D04`        | 兼岗结束         | concurrent-employment slot ends     |
| `D07`        | 人员分类转换     | personnel re-classification         |
| `D08`        | 调往德国数据中心 | cross-tenant move to the EU SF DC   |

Helix never closes accounts for these: every ingest path (webhook AND
the daily OData reconciliation sync) checks the termination reason
against a configured exclusion list before acting. You may filter these
events in the iflow, but the preferred contract is to **send them with
`termination_reason` set** — Helix then records an `offboard_skipped`
ledger row ("received, classified as transfer, account kept open") that
both sides can audit later. New no-closure reasons must be communicated
to the Helix team BEFORE first use so the exclusion list is updated.

For the cross-DC moves (`D03`/`D08`): the target tenant's subsequent
hire event for the same person (employee number unchanged) is treated
by Helix as a transfer, not a new hire — no second mailbox or Google
account is created.

---

## 5. Multi-region URL routing

PingCAP runs separate SF tenants per region (CN, EU). Each Helix
integration row has a `slug`; CPI should POST to the slug-scoped URL so
incoming events get tagged with the correct `hris_instance_id`:

| SF tenant region | Helix slug   | URL                                                                  |
| ---------------- | ------------ | -------------------------------------------------------------------- |
| CN production    | `sf-cn-prod` | `https://helix.pingcap.net/api/v1/hr/successfactors/webhook/sf-cn-prod` |
| EU production    | `sf-eu-prod` | `https://helix.pingcap.net/api/v1/hr/successfactors/webhook/sf-eu-prod` |
| Local dev        | n/a          | `http://localhost:8000/api/v1/hr/successfactors/webhook`                 |

The unslugged URL `…/successfactors/webhook` is retained for single-
tenant installs and should NOT be used in PingCAP prod.

---

## 6. Pre-flight: `_diagnose` endpoint

Before flipping the CPI iflow to the apply URL, point it at `_diagnose`
(same path + `/_diagnose` suffix). The endpoint:

* runs the **identical** auth envelope (token + timestamp + HMAC)
* parses + validates the body schema
* looks up every `user_id` against Helix's person directory
* returns a per-event diagnostic
* writes **nothing** to the database

Sample request and response:

```http
POST /api/v1/hr/successfactors/webhook/sf-cn-prod/_diagnose
```

```json
{
  "signature_ok": true,
  "schema_ok": true,
  "events_received": 2,
  "resolved_persons": 1,
  "unresolved_user_ids": ["100503"],
  "events": [
    {
      "user_id": "100501",
      "event_type": "onboard",
      "effective_date": "2026-08-15",
      "person_id_external": "10050",
      "person_match": {
        "person_id": "0a5b…",
        "employment_status": "active"
      },
      "would_apply": true
    },
    {
      "user_id": "100503",
      "event_type": "offboard",
      "effective_date": "2026-06-30",
      "person_id_external": null,
      "person_match": null,
      "would_apply": true
    }
  ],
  "hris_instance_slug": "sf-cn-prod"
}
```

> **Minimum-disclosure note:** `person_match` deliberately returns ONLY
> `person_id` (a non-enumerable UUID for cross-referencing) and
> `employment_status`. It does NOT echo `display_name` / `work_email` /
> `hire_date` / `termination_date` — anyone holding the webhook secret
> could otherwise sweep `user_id` ranges through `_diagnose` and dump the
> directory. A `null` `person_match` means Helix has no person row for
> that `user_id` under the SF source-system (normal for a future
> `onboard`, suspicious for an `offboard`).

> **Cross-tenant note (2026-07-31):** a `person_match` carrying
> `"cross_instance": true` means the person resolved, but their Helix
> row is currently anchored to the OTHER SF tenant (common for overseas
> employees whose termination is entered in the EU tenant while the CN
> tenant's sync created their record). This is fine — an `offboard`
> pushed from either tenant is applied against the person's real record
> (Helix re-anchors it to the pushing tenant at apply time). You do NOT
> need to route the event to the other region's URL.

Use this for iflow smoke tests in CPI dev / test environments. Note the
PingCAP `sf-cn-dev` / `sf-eu-dev` slugs are currently **disabled** in
prod (they return `404`); smoke-test `_diagnose` against the live
`sf-cn-prod` / `sf-eu-prod` slugs instead — `_diagnose` writes nothing,
so this is safe.

---

## 6a. Response contract (fast-ACK, since 2026-07-01)

The **real** webhook (not `_diagnose`) authenticates, records a receipt
audit row, and returns immediately — it does NOT wait for the event to be
applied. A successful POST returns `200`:

```json
{
  "success": true,
  "data": { "accepted": true, "events_received": 2 }
}
```

`events_received` echoes how many events Helix parsed from your body; it is
**not** a count of what was applied. The apply (person upsert, lifecycle
ledger, email write-back, onboarding case, and — on/after the effective
date — account provisioning / offboarding cleanup) runs on a background
task and is reconciled by the full-sync backstop if that task dies.

Why: applying an event inline meant ~13 SuccessFactors OData round-trips
(master-data reload + user/job fetch) plus downstream provisioning per
event, against SF's datacenter — routinely tens of seconds, past the
gateway proxy timeout, so SF's integration center logged `504 Gateway
Time-out` and replayed. Fast-ACK removes that class of failure entirely.

Status codes you can still get **synchronously** (before the background
apply): `401` (bad/expired signature or Basic credential), `403`
(source IP not in the allowlist), `409` (mass-leaver brake — a single
batch exceeding `max(25, 5% of active)` `offboard` events is refused
outright, never applied), `400` (unparseable body). The brake stays
synchronous precisely so a forged bulk-termination payload surfaces as a
`409` rather than being accepted-then-silently-refused.

---

## 7. Recommended CPI iflow shape (Apache Camel pseudo)

```
from(IntelligentServicesEvent)              // subscribe to Employee Hire / Termination
  .filter(eventTypeAllowList)               // ignore JobInfoChange for now
  .process(mapToHelixSchema)                // SF object → Helix events[]
  .marshal(jsonWriter)
  .process(stampTimestampHeader)            // X-SF-Timestamp = now() in ms
  .process(computeHmacSignature)            // X-SF-Signature = sha256=HMAC(secret, ts + "." + body)
  .setHeader("X-Helix-Webhook-Token", "${properties:helix.webhook.secret}")
  .setHeader("Content-Type", "application/json")
  .to("https4://helix.pingcap.net/api/v1/hr/successfactors/webhook/sf-cn-prod")
  .process(handleHelixResponse)             // on 200: ok; on 401/409/4xx: alert; on 5xx: retry w/ exp backoff
```

Notes:

* **Retry strategy:** on `5xx` retry with exponential backoff
  (1m / 5m / 30m / 4h, give up after 4h). On `401` do NOT retry — it
  indicates secret rotation or a malformed signature; alert ops.
  On `409` (mass-leaver brake) do NOT retry — the operator at Helix
  handles it manually.
* **Idempotency:** Helix dedupes on `(person, event_type, effective_date)`.
  Since the fast-ACK change the webhook response is `200 {accepted: true,
  events_received: N}` and does NOT carry per-event outcome (the apply
  runs in the background). A retry that arrives after the first apply is
  still deduped server-side — the second background apply finds the
  lifecycle-ledger row already exists and re-fires nothing. Verify dedup
  via Helix's audit log / lifecycle-events view, not the HTTP response.
  The wire is at-least-once by design, so retries are safe.
* **Batch size:** group events from the same minute, up to 500 per
  request. Larger batches must be split. The mass-leaver brake also
  caps a single request to `max(25, 5% of active)` `offboard` events.
* **Logging:** record CPI's view of every request (timestamp + status +
  response body) for 90 days. Helix audits every accepted event too, so
  having both sides simplifies post-incident reconciliation.

---

## 8. Verification on the Helix side

Once the iflow is live, Helix admins watch:

* `Admin → HR → HRIS 事件 ledger` — every received event with its
  `trigger_source`. Webhook events show `trigger_source=webhook` in
  green. Sync confirm events (the reconciliation backstop) show
  `trigger_source=sync_confirm` in blue. The webhook count should
  dominate; a sustained spike in sync_confirm means SF is not pushing
  reliably.
* `Admin → Audit` filter on `hr.successfactors_webhook.receive` —
  the per-call audit log including counts.
* `Admin → HR → HRIS 同步` — the sync-batch reconciliation surface;
  the same console used pre-event-driven for manual confirm. Should
  rarely fire coordinator hooks now (it skips when the webhook already
  handled the event).

---

## 9. Cutover & rollback

**Cutover:**

1. CPI iflow built, smoke-tested against `_diagnose` from CPI staging
2. CPI promoted to CPI prod, still pointed at Helix `_diagnose`
3. Helix admin watches `_diagnose` audit log for 24h to confirm
   `events_received` matches SF's expected rate
4. CPI iflow URL flipped from `_diagnose` to the real apply endpoint
5. First real `onboard` / `offboard` event verified end-to-end (event
   visible in the ledger, downstream provisioning / offboarding
   triggered)

**Rollback:**

If something goes wrong, the daily HRIS sync remains in place as a
backstop — disabling the CPI iflow's apply target leaves the daily sync
to pick up missed transitions. No data loss; only a per-event lag of
up to 24h.

---

## 10. Open questions to resolve before cutover

1. **CPI license:** confirm PingCAP's SAP contract includes Cloud
   Integration. If not, procurement + implementation must happen first.
2. **Secret distribution channel:** which password manager / vault is
   the agreed handoff route? (Currently assumed 1Password.)
3. **Region split:** confirm the SF tenant slug list — currently CN +
   EU prod. Are there sandbox tenants that need their own slug for
   testing?
4. **JobInfoChange wiring:** Helix needs role-change automation
   eventually. Track in Helix backlog; ship after the onboard / offboard
   pair is stable in prod.
