# Integration spec: <flow_id>

Status stays draft until the approvers below sign. Nothing in this document changes a live system.

## Header

- Flow ID: <flow_id>, version <n>, status <draft | approved>
- Mode: <Design | Review | Recover>
- Tier: <1 | 2 | 3> because <what the flow can do: contact a customer, change consent, move money, create sales work, change decision fields, informational copy>
- Business owner: <role>. Technical owner: <role>. On-call: <role or rota, required for tier 1>
- Date: <YYYY-MM-DD>. Approvers: <roles>

## Decision

- Sentence: <source fact> becomes <destination state or action> so that <who> can <decide or act>.
- Who acts on it: <team and the action they take>
- If the data is stale or wrong: <business consequence, for example a renewal call is missed or a customer gets a message they opted out of>

## Method

- Chosen: <native | ipaas | event_pipeline | reverse_etl | custom | file | mcp>, pattern card <name or none>
- Simpler methods and the clause each failed:
  - Native connector: <clause, for example "cannot aggregate several subscriptions per account">
  - iPaaS: <clause>
  - Event pipeline: <clause or not applicable>
  - Warehouse plus reverse ETL: <clause or not applicable>

## Systems and objects

- Source: <system, account or environment name, object, API version>
- Destination: <system, account or environment name, object, API version>
- Environments: <sandbox and production names; never credentials>

## Trigger

- What starts a run: <event name and subscription, schedule and interval, manual action, or file arrival with its completion marker>
- What does not start a run: <for example the integration's own writes, or form views before acceptance>
- Incremental reads (schedule or polling): inclusive compare from the last durable cursor minus the overlap <hours> (BF04), dedupe by key plus version, cursor advanced after durable writes only

## Identity

- Match key: <field> holding <stable source ID>, stored in the destination field <name>, which enforces uniqueness by <unique value property | External ID plus Unique | primary key>
- Crosswalk: <where it lives; columns source_system, source_id, destination_system, destination_id, entity, status, first_seen, last_verified>
- Two candidate matches: no write; exception with both record IDs to <owner>
- No match: <create, because this flow owns creation | exception to owner>
- Normalisation: <case rule, leading zeros kept as text, Salesforce 18-character IDs, trimming>
- Merges and deletes: <crosswalk moves to the survivor; tombstones stop stale writes recreating deleted or erased records>

## Field ownership

- Field map: <path to the field-map CSV rows for this flow>
- Conflict rule: <version | effective time>; last write wins only where arrival order equals authority
- Writes carry an allowlist of owned fields and skip writes that change nothing
- Manual overrides: <calculated field, override field with reason and expiry, effective value>
- Loop prevention: <how the flow ignores its own writes>

## Values

- Enums: <allowed values; unknown values quarantined, never mapped to a default>
- Money: <amount basis (gross, net, tax, fees), ISO 4217 currency, mixed-currency rule>
- Time: <date or instant, time zone, occurrence time and receipt time stored separately>

## Removals

- Unsubscribe: <behaviour per destination>
- Audience exit or no longer eligible: <clear fields | exit audience | none, with reason>
- Delete or erasure: <behaviour and who confirms>
- Merge: <behaviour>
- Minimum-population guard: stop and ask for approval when one run would remove more than 10% of the destination population or more than 1,000 records

## Freshness

- Target: <minutes> from <source event> to <destination accepted>, set by the decision
- Measured as: source-through time <how>, processing time <how>, destination acceptance <how>
- Late events: <how an old event is kept for history without restoring an obsolete state>

## Idempotency

- Delivery key: <sender's event ID>
- Business effect key: <flow ID + source object ID + business version>
- Ledger: <store, unique constraint, columns>, retention <days> (at least the longest retry, resend, replay or backfill overlap window: <which>)
- Claim: atomic insert or unique constraint before the side effect, never read-then-create
- Unknown outcome (timeout after send): read the destination by the stable key, or use <vendor idempotency mechanism>, before any retry

## Ordering

- Rule: <refetch the authoritative object on each event | compare versions and drop stale writes>
- Serialisation: <per account or per object, how>

## Retries and errors

- Error classes: transient <statuses>, quota <statuses and body codes>, permanent <statuses>, unknown outcome <cases>, hidden failure <for example HTTP 200 with errors in the body, per-record failures in a batch>
- Backoff: exponential with full jitter, base <1 s>, factor <2>, cap <60 s>, at most <6> attempts, then the exception queue
- Retry layers and attempts: <layer:attempts>; product of nested attempts <n> (10 or fewer)
- Retry-After unit: <seconds | milliseconds | HTTP date>, per <vendor doc URL> (a receiver behind a HubSpot workflow webhook sends milliseconds)
- Exception queue: <where>, owner <role>, re-drive at <15 min, 1 h, 6 h>, reviewed <daily for tier 1, weekly for tier 2>

## Receivers (inbound webhooks only)

- Signature: <scheme and version pinned>, verified on raw bytes, timestamp tolerance <minutes>, constant-time comparison
- Tenant check: account or portal ID validated after the signature, before any lookup
- Durable receipt, then 2xx; acknowledgement p99 <seconds> against the sender's <seconds> timeout (at most half)
- Unknown event types: acknowledged and stored, not processed
- Malformed payloads: <reject with 4xx | store and quarantine>, per the sender's contract

## Rate budget

- Initial backfill: <paste size_sync.py output line and verdict>
- Normal day: <output and verdict>
- Replay after the longest acceptable outage: <output and verdict>
- Preset and check date: <preset name, date, source URL>; reset rule <text>

## Backfill and recovery

- Pilot: <100 records or 1%, whichever is smaller, never fewer than the edge cases in the test plan>, listed in <file>
- Batches: <size>, parents before children, checkpoint after durable writes only
- Holds: <workflows, sequences, journeys paused or filtered> and the re-enable step after the read-back passes
- Recovery sources and windows: <for example webhooks journal, resend, replay, search windows with overlap>

## Alerts and runbook

- Oldest unprocessed item older than <freshness target>
- Exceptions: <tier rule>
- Volume under 50% or over 200% of the median of the same weekday over the previous 4 weeks (when that median is 20 or more)
- Quota above 80% of the daily allowance
- Subscription disabled, banned or deleted
- Credentials expiring within 14 days
- Read-back drift above <tier threshold>
- Runbook: <path>

## Access

- Integration identity: <service account or app name, never a person>
- Scopes: <least privilege list>
- Secret location: <secret manager or iPaaS connection store, by name>; rotation <date or interval>
- Versions pinned: <API versions> and the deprecation calendar entry

## Privacy and consent

- Personal data mapped: <fields>; minimised to <what is needed>
- Consent and eligibility: <rule applied at the action boundary, owner>
- Logs and alerts: no payloads or personal data
- Retention and erasure: <policy owner and behaviour>

## Tests

- Test plan: <path>; evidence summary: <cases passed, failed, blocked>
- Read-back: <reconcile_readback.py command and summary line, with every owned field listed under Owned fields compared>
- Design mode: sandbox runs and the pilot read-back have not happened yet; list them under Open decisions as owed before go-live

## Open decisions

- <question>: owner <role>, needed by <date>

## Approval

- <role>: <date>
- <role>: <date>
