Writing · Case study · 2026 · Permission Layer

Sentinel:CRM is the permission layer

Revenue-intelligence runs on the most sensitive data a company holds. So the first question isn't "what model scores the deal" - it's "what are we even allowed to read?"

fig. 00 results

CRM
is the ingestion permission gate
0
OAuth providers, one boundary
AES-256-GCM
per-integration secrets at rest
Fail-closed
on any verification error

Section 01

The problem

Every revenue-intelligence tool runs on the same fuel: the conversations around a deal - email, calendar invites, chat. That's also the most sensitive data a company holds.

So the first real question isn't "what model scores the deal," it's "what are we even allowed to read?" Most tools treat that as an afterthought, and it surfaces as one of two failure modes.

One: ingest everything. Connect a mailbox, pull every thread, read the whole Slack workspace. You get great coverage and a surveillance liability - employees' private messages, vendor negotiations, HR threads, all sitting in a third-party database with no principled boundary. Security review kills the deal, or should.

Two: ingest nothing useful. Make the user tag each thread by hand, or limit ingestion to a manually-maintained allowlist. Safe, and dead within a month - nobody maintains it, coverage rots, and the product degrades into a worse CRM.

Sentinel takes neither. It binds ingestion to a boundary the business already maintains and already trusts: the CRM.

The first real question isn't "what model scores the deal," it's "what are we even allowed to read?"

Section 02

The thesis: CRM is the permission layer

A CRM contact book isn't just a list of people. It's an organization's standing answer to "who are we in a business relationship with."

Sales already curate it, dedupe it, and keep it current - their job depends on it. That makes it the right ACL for data ingestion, and it's already being maintained for free.

So the rule is simple and absolute: a message is ingested only if at least one participant matches a Contact synced from a connected CRM. An email whose sender and recipients are all strangers is dropped. A Slack message from someone not in the book is dropped. A calendar event with no CRM attendee is dropped. The check runs on every inbound item - after self-exclusion (the user's own address never counts) and email normalization - against a Contact table populated by read-only HubSpot and Salesforce sync.

This beats the alternatives on every axis that matters. Manual allowlists drift and demand maintenance nobody does. Domain allowlists (@acme.com) over-admit - they pull in a prospect's entire company, including people you've never dealt with. Shared-secret or header gates authenticate the sender, not the relationship. CRM membership is the only gate that's self-maintaining and actually models the business relationship.

It is also fail-closed. The permission check returns "not in book" on any database error rather than admitting data it couldn't verify - a backend hiccup can never silently widen the ingestion boundary. And it is PII-blind: the filter logs outcome counts and a fixed enum of reason tokens (passed, dropped_no_crm_match, dropped_self_user, fail_closed), never an email address or message body.

Section 03

Architecture

Read-only in, gated, persisted or dropped. CRM sync populates the Contact book; every inbound message is checked against it, and only messages with a CRM-matched participant are stored.

FIG. 01

CRM sync - read-only, never writes back

HubSpot / Salesforceread-only OAuth
Contact table@@unique(userId, email)

Inbound - every item is checked

Gmail / Calendar / Slackinbound message
Self-exclusionuser's own address
Normalize emailthen match
Batched lookupindexed query

Outcome

Match≥1 CRM participant
Persist+ contact IDs · passed
No matchincrement metric, drop
FIG. 01 - Each inbound message's participant emails are checked against the Contact table in a single batched query that rides the @@unique([userId, email]) index. On a match, the message is persisted with the matched contact IDs and an outcome of passed; otherwise only a metric is incremented. The gate is the product, not a feature.

Section 04

Why this is hard

The thesis is one function. Making it hold up in production is the work.

  • 01Three OAuth providers, three token-expiry models

    Slack bot tokens don't expire, so there's nothing to refresh. HubSpot issues a short-lived access token plus a refresh token, with the server's expiry stored per integration (tokenExpiresAt). Salesforce's token response carries no expires_in at all - so the access token is treated as lifetime-based and cached for a fixed 110-minute window with a safety buffer, then refreshed. One code path, three different truths about when a credential goes stale.

  • 02HMAC verification under clock skew

    Slack signs each request over v0:timestamp:rawBody. Verification must use the raw bytes (a JSON.parse/stringify round-trip changes the digest), reject anything outside a 300-second replay window, and compare with timingSafeEqual after a length pre-check - because timingSafeEqual throws on unequal-length buffers.

  • 03Idempotent ingestion under at-least-once delivery

    Slack retries events; the same event_id can arrive several times, sometimes concurrently. Dedup is a @@unique([userId, slackEventId]) index plus a cheap pre-check, with a P2002 catch on insert to absorb the race where two retries pass the pre-check at once. Gmail uses @@unique([userId, externalId, source]).

  • 04CRM contact-set drift

    Admission is decided at ingest time, not re-evaluated forever. If a Contact is deleted after a message was stored, that message keeps the contact IDs it matched at the time - history doesn't rewrite itself when the book changes.

  • 05Secrets at rest with a rotation story

    Bearer tokens for five providers live in the database. They're encrypted with a versioned envelope so the format and key can evolve without a migration that assumes plaintext.

Section 05

Three providers, three truths about a stale credential

One code path resolves credentials for Slack, HubSpot, and Salesforce - each of which tells a different story about when its token goes stale.

FIG. 02

Slack - never expires

Bot tokenno expires_in
Use as-isnothing to refresh

HubSpot - server-provided expiry

Access + refreshshort-lived
tokenExpiresAtstored per integration
Refresh on expiry

Salesforce - no expiry returned

Token responseno expires_in
Cache 110 min+ safety buffer
Refresh after window
FIG. 02 - Slack has nothing to refresh. HubSpot stores a server-provided expiry per integration. Salesforce returns no expires_in, so its token is treated as lifetime-based and cached for a fixed 110-minute window with a safety buffer.

Section 06

Idempotent ingestion under at-least-once delivery

Slack retries events; the same event_id can arrive several times, sometimes concurrently. Every duplicate has to collapse to one stored row.

FIG. 03

Slack - at-least-once

event_idarrives ≥ once
Pre-checkalready seen?
Unique indexuserId · slackEventId
P2002 catchabsorbs the race
duplicate_eventone row, no double-write
FIG. 03 - A cheap pre-check handles the common case; the @@unique([userId, slackEventId]) index is the real guarantee; the P2002 catch absorbs the race where two retries pass the pre-check at once. Gmail uses @@unique([userId, externalId, source]).

Section 07

Design decisions & tradeoffs

01

Read-only OAuth scopes

  • Why: gmail.readonly / calendar.readonly and read-only CRM scopes mean Sentinel cannot modify a customer's inbox, calendar, or CRM - smaller blast radius, an honest consent screen.
  • Tradeoff: no auto-creating Contacts and no writing scores back into the CRM. Enrichment is one-directional.
02

Per-integration encrypted token storage

  • Why: tokens are bearer credentials; TLS protects them in transit but not at rest, so each secret is stored as an AES-256-GCM envelope.
  • Tradeoff: more moving parts than plaintext-plus-TLS, and a key that must be present for any integration to function.
03

CRM membership as the gate

  • Why: self-maintaining, and it models the real relationship.
  • Tradeoff: a prospect not yet added to the CRM is invisible - coverage is only as good as the book. A boundary that under-admits beats one that over-admits.
04

HMAC-signed, stateless OAuth state

  • Why: the HubSpot and Salesforce flows carry CSRF state in an HMAC-signed cookie verified with timingSafeEqual - no server-side session store, and a forged cookie can't swap the userId mid-flow.
  • Tradeoff: shared-secret management; the Gmail/Calendar flows still use plain JSON state cookies - signing them is a deliberate follow-up.
05

Risk recomputed on read

  • Why: deal risk is derived from timeline events on each read, with auditable reason strings - no stale precomputed score to invalidate.
  • Tradeoff: cost grows with pipeline size; very large pipelines would need precomputation or caching.

Section 08

Failure modes

01

Slack token revoked / rate-limited

  • The email resolver throws; the handler fails closed, drops the event with a fail_closed metric, and persists nothing.
  • Dropping one message beats admitting one we couldn't validate.
02

Provider rate limits / transient 5xx

  • External calls go through retry-with-backoff and a per-provider circuit breaker.
  • An open circuit short-circuits instead of hammering a degraded provider.
03

Partial failure mid-sync

  • Syncs upsert by external ID, so a re-run is idempotent and a half-finished sync leaves no duplicates.
  • Each provider emits structured completion metrics.
04

Postgres connection-pool exhaustion

  • The app uses the pooled Supabase URL (port 6543, pgbouncer); migrations use the direct URL (port 5432).
  • The permission check fails closed on any DB error, so pool pressure degrades to dropped ingestion - never to over-admission.
05

Duplicate event / webhook delivery

  • Unique indexes plus P2002 handling collapse retries to a duplicate_event outcome.
  • No double-write.

Section 09

Security model

01

Secrets at rest

  • AES-256-GCM, envelope format enc:v1:<iv>:<tag>:<ciphertext> with a random 96-bit IV per secret and the GCM auth tag stored inline.
  • A single 32-byte KEK from INTEGRATION_ENCRYPTION_KEY; legacy plaintext rows still decrypt.
  • The v1 prefix exists so the format or key can be rotated later.
02

HMAC in three places

  • Inbound Slack requests are signature-verified with a 300-second replay window.
  • OAuth state cookies are HMAC-signed and timingSafeEqual-checked.
  • Outbound webhooks are signed with an X-Webhook-Signature (HMAC-SHA256) header.
03

Read-only and private

  • Read-only OAuth scopes; Sentinel never writes back to a connected system.
  • Pipeline data is never used to train shared models.
  • The permission filter never logs message text or participant emails.

Section 10

Testing

The suite is aimed at the parts where a bug is a security or correctness incident - the permission boundary, the risk scoring, token handling - not at coverage for its own sake.

  • 01Where the tests are aimed

    61 Vitest unit-test files concentrate on the load-bearing logic - the CRM-membership ingestion gate, deal-risk scoring, OAuth token handling, and the server actions - with Prisma, auth, and the external APIs mocked so a single rule change is caught in isolation.

  • 02A guard against claim drift

    A generated suite checks twelve monitored files against the banned phrases and contradiction rules in PRODUCT_CLAIMS.md, so the product's stated claims can't silently drift out of sync with what the code actually does.

  • 03The deliberate tradeoff

    Integration tests against a live database aren't part of the suite - the unit layer mocks Prisma and the providers - the Playwright end-to-end specs are mostly held as skipped scaffolding, and CI runs the suite on demand rather than on every push.