The policy-scan pipeline model
The pre-send policy scanner page gives you the checklist view: which rules fire, how to read the violation response, how to flip your mode. This page is the plumbing model behind it — where the scan sits in the request lifecycle, how the verdict rides the transport, and where the findings land after the verdict, so you can reason about the pipeline when you integrate against it.The scanner is a tenant-owned control. Your organization picks the mode;
Orbit does not gate outbound globally on it and never forces a mode on you.
This page documents the plumbing of a control you own — it is not legal
advice.
1. Where the scan lives
Every scan happens in a single FastifypreHandler — one hook that runs
before the route handler on any send route opted into scanning. A send route
opts in by setting policy: true (or policy: { channel: "sms" } and
optionally a mode) in its route config, and the hook extracts the fields it
needs — channel, recipient, content body, optional sender name, scheduled time,
media URLs, recipient country, subject, and traffic-lane metadata — straight
off the request body.
Two consequences of that placement matter to you as an integrator:
- A body with no recipient or no content skips the scan cleanly. No
recipient → the hook returns without scanning. A channel with no applicable
rule → the scanner returns an immediate
pass. The pipeline never throws on partial input. - The scan runs and decides before your handler touches the message. An
intercepted
POLICY_VIOLATIONrejection arrives before anything was stored or queued — you never have a stored-but-rejected artifact to reconcile.
enforcePolicy calls made inside service code follow the identical
verdict → mode → enforcement chain; the hook is just the transport-attaching
wrapper around that chain.
2. The verdict vocabulary
Every matched rule collapses to one of three verdicts:pass— no rule fired. The send proceeds.warn— advisory rule(s) fired. The send proceeds; findings ride the transport as described below.block— a blocking rule fired (SHAFT content, spam score ≥ 80, DLP sensitive data, unregistered-sender country gate) or TCPA quiet-hours promoted to enforceable in strict mode.
block verdict actually gates
the send is a mode question, which is the next section.
3. The mode matrix — verdict at the transport layer
Your organization’s policy scan mode decides exactly what a verdict does. The mode resolution order is: explicit per-route override > org-level setting >warn default — a route that pins its mode beats your org
setting, and your org setting beats the fallback.
The three rows decode to a single rule: the mode is a transport decision
about what to do with the verdict —
off short-circuits the scan itself,
warn maps every verdict to “send + surface the finding,” and strict maps
a block verdict to a synchronous request rejection.
Why the 400/422 asymmetry exists. Content-class blocks (SHAFT, spam
score, DLP, sender gate) reject with 400 because the rendered client already
branches on POLICY_VIOLATION-400 to show an inline toast. A violation whose
severity is enforce — the TCPA quiet-hours promotion from warn in strict
mode — instead raises 422 so it lines up with the send-gates guard’s own
TCPA_QUIET_HOURS 422. The hook derives this from the severity attached to
each violation, so you can tell from the status code whether you violated a
content rule or a timing rule without parsing the body.
Fail-closed on lookup failure. If the org-mode lookup throws and the hook
cannot resolve your mode, the send path fails closed (503 POLICY_SCAN_MODE_LOOKUP_FAILED / POLICY_SCANNER_UNAVAILABLE) rather than
sending unscanned. Retry; those are scanner-outage codes, not policy verdicts
and they never imply one.
4. The header contract and its size guard
Every violation the scanner produced — in every mode, block or pass — is appended to the response’sX-Policy-Violations header as a JSON array of
finding objects, one per rule, each carrying rule, severity (enforce /
block / warn), a message, and a suggestion. This is what the dashboard
compose surface uses to show warnings when nothing blocked.
Three transport realities govern the header contract:
- HTTP headers are ASCII-only (RFC 7230); the scanner’s messages are not.
Em-dashes, curly quotes, and non-ASCII category labels would crash the
server with
ERR_INVALID_CHAR. Before transport, every non-ASCII byte is converted to a\uXXXXescape inside the JSON string, so the header rides the wire as pure ASCII and your client stillJSON.parse()s it back to the original text. - The header has a bounded budget: 6,144 bytes. Fastify’s default
~8 KiB header limit means an oversized findings array must not drown the
response. When the full payload fits within the budget it ships whole; when
it exceeds the budget, the header keeps the first three violations and
appends a synthetic marker object with
rule: "_truncated"telling you how many more were dropped. On overflow, look at logs or the message metadata for the full list — the header tells you it happened rather than silently serving a partial list with no signal. JSON.parse()the header, don’t substring it. The escaped form is the transport form, not the finding itself — parse, then read each object’srule/severityfields.
5. The audit trail — findings in metadata, blocks in the audit log
The transport header is how you see a finding live; the audit trail is how you see it after:- Stored-on-the-message metadata. Findings are stamped onto the message’s own metadata (the message record carries the violation list), so a dashboard that renders a message, or a downstream webhook consumer that receives that message record, can re-render its policy findings without touching a separate lookup surface.
- Immutable block events. Every strict-mode block writes an audit event —
policy.message_blocked— to the tamper-evident audit chain with the channel and the violation rules. This is the irreplaceable leg of the trail: a hook-levelinfolog that lives in log-aggregator retention is fine for operators, but a content-moderation block needs a record that outlives the window, so the hook emits it before it rejects the send.
6. The rule catalog
Each rule in the catalog applies to specific channels, and channels with no applicable rule return a cleanpass. The policy scanner
checklist enumerates them; at the pipeline level
the catalog covers:
- TCPA quiet hours — marketing sends outside recipient-local 08:00–21:00.
Transactional traffic is exempt via the traffic-lane hint the hook
resolves; in strict mode this rule’s severity is promoted from
warntoenforce. - SHAFT content — sex, hate, alcohol, firearms, tobacco/cannabis keywords on US SMS.
- Missing opt-out — promotional content without an opt-out phrase.
- Short URLs — public shorteners carriers aggressively filter.
- Spam-keyword score — SpamAssassin-style 0–100 score; ≥ 80 blocks, 60–79 warns.
- GDPR sender identity — email to EU recipients must name a From display name and a postal address.
- Country registration & sender gate — unregistered or mismatched sender on countries that require registration.
- DLP — sensitive data — card / PAN, US SSN, IBAN. Findings carry offsets and category only, never the matched text.
7. Why the mode is tenant-owned
The mode lives on your organization record and is picked by an owner in your workspace — every send resolves it before the verdict is enforced. Orbit does not force a platform mode on you and does not gate outbound globally; the mode control is tenant-owned by deliberate design, unlike the one platform-global carve-out on the compliance map (the TCPA federal voice guard — see policy-scanner tenancy posture). When you change the mode, the change applies on the very next send.8. Where this page is one of three views
Read it with Tenant compliance defaults — the opt-in
envelope (the sibling
opt-in-envelope model for org-wide defaults) and Send
gates (the sibling gate chain on the same send
path).