Architecture Deep Dive

Three-pillar interaction model, request lifecycle data flow, shared module architecture, latency budget breakdown, cascade revocation mechanics, and multi-tenant isolation guarantees.

Last reviewed September 18, 2026Fresh

Overview

This guide traces the real request paths through the code: how a signal moves through shield-ingest, which shared modules every function depends on, where the decision path spends its time, how the revocation cascade executes, and how one organization's data is kept away from another's.

For the product-level tour, see What is PasskeyBridge?.

SectionFocus
Three-pillar interaction modelWhat each pillar produces and which other pillar consumes it
Shared module architectureThe modules every edge function imports, and who imports them
Request lifecycleThe ordered steps of one ingest request
Latency budget breakdownWhere the decision path spends its time
Cascade revocation mechanicsClassification, the four branches, and dry-run
Multi-tenant isolationRow-level security, code-level scoping, and key separation

Three-pillar interaction model

The pillars exchange artifacts, and the exchanges that are automatic are fewer than the model suggests. This table separates the two.

ProducerArtifactConsumerAutomatic today
I Network attestationHard signal (sim_swap, account_takeover, ...)Agentic layerYes. Inside the ingest request, active delegates are deactivated with trust_score = 0 and active A2A negotiations move to revoked
I Network attestationSoft signal (velocity_anomaly, ...)Agent trust engineYes, asynchronously. A fire-and-forget call to shield-agent-trust, debounced to one per organization and signal type per 30 s
I Network attestationHard signalII Credentials, III PasskeysNo. Credential suspension and passkey invalidation happen when a playbook action or an API call asks for them
II CredentialsVerified credentialCross-reference engineNo. shield-cross-reference binds a subject on request
III PasskeysRegistered credentialCross-reference engineNo. Same, on request

The inline revocation. When the signal type is in the hard list and an identifier was supplied, shield-ingest fires both updates together with Promise.all and reports the counts as a parametric_revocation entry in actions_executed, with an audit row recording the same counts. It runs before the playbook actions, deliberately: revocation is the decision's immediate consequence and must not queue behind a Slack post or a webhook to your own backend.

Hard signal -> shield-ingest
  |- revoke agent delegates      (is_active false, trust_score 0)
  |- revoke A2A negotiations     (status revoked, scopes emptied)
  |- then run the playbook actions in parallel

The four-subsystem cascade (delegates, A2A negotiations, shadow identities, BLAST tunnels) lives in _shared/cascade-orchestrator.ts and is invoked from two places: shield-cascade, which requires an organization-admin session and the Enterprise plan, and shield-sse-caep when an inbound, signature-verified CAEP event maps to a hard signal. Ingest itself revokes the first two.

The soft path does not block the response. The ingest handler records a graduated_trust_evaluation entry with mode: "async" (or mode: "debounced") and returns; the trust engine re-scores the organization's delegates afterwards.

Shared module architecture

Every edge function imports its authentication, validation and response handling from supabase/functions/_shared/ rather than reimplementing it. The Used-by column is taken from the imports as they stand.

ModuleResponsibilityUsed by
cors.tsjsonResponse, errorResponse, optionsResponse, the shared header set, and assertEdgeProxy which refuses a request that did not come through the Cloudflare workerNearly every function
api-key-auth.tsOne authentication entry point: session JWT with an is_tenant_admin check, or an API key matched by SHA-256 digest, with optional scope and quota enforcement16 functions, including shield-dpop, shield-blast, shield-cascade, shield-vc-issue, shield-predict, shield-supply-chain, shield-passkey-rp. shield-ingest authenticates inline instead
tenant-resolver.tsOrganization lookup by id (14 columns, decrypting the signing secrets), membership resolution, and id extraction from the path or the x-pb-tenant-id headerEvery organization-scoped function
ingest-context.tsThe whole ingest decision context in one round trip: organization row, API-key row, matching playbook, rate-limit counter, each with its own error fieldshield-ingest
ingest-validation.tsZod schema for the ingest body, run before any database callshield-ingest
ingest-auth.tsPure decisions: key scope, inbound signature policy with the 24-hour rotation grace, subject_ref validationshield-ingest
identifier-hash.tsKeyed HMAC-SHA-256 digests for phone, email, subject, IP and network landmarks, with pepper rotation support. Throws when the pepper is missingEvery function that touches an identifier
sha256.tsPlain SHA-256 for high-entropy secrets and content digests (API keys, tokens, SBOM bytes)API-key lookup, attestation, provenance
encryption.tsAES-256-GCM encrypt and decrypt under the platform key SHIELD_ENCRYPTION_KEY18 functions, including shield-admin-mutations, shield-sse-caep, shield-shadow-proxy, shield-webhook-worker
pii-vault.tsPurpose-bound decryption with per-access audit loggingshield-pii-migrate, send-marketing-email
rate-limiter.tsL1 in-memory damper plus the database-backed distributed counter, and the plan limit tablePublic-facing functions
cascade-classifier.tsPure map from signal type to hard, soft or unknownshield-cascade, the predictive ladder, the attack-vector library
cascade-orchestrator.tsThe four-branch parallel revocation, with dry-runshield-cascade, shield-sse-caep
agent-trust-engine.tsBehavioral scoring and scope narrowing, plus isHardSignal and isSoftSignalshield-agent-trust, shield-ingest
ingest-actions.tsOne handler per playbook action, each returning a uniform result with its own latencyshield-ingest
audit-logger.tsAppend-only writes to shield_audit_log with actor contextEvery mutating function
correlation.tsCorrelation ids: the prefix pb-, the timestamp in hex, and 8 hex characters of randomness, 23 characters at present millisecond widthsshield-ingest, shield-cascade, shield-sse-caep
verify-set.tsJWKS-backed verification of inbound Security Event Tokens, fail-closedshield-sse-caep
blast-crypto.ts, blast-sessions.tsX25519 agreement, HKDF derivation, AES-256-GCM tunnels and their session lifecycleshield-blast

Logic modules hold no I/O. agent-trust-engine.ts, cascade-classifier.ts, ingest-auth.ts and caep-cascade-mapper.ts contain no database calls at all: they take data and return a decision, so each rule is pinned by a unit test that needs neither a database nor a network.

Request lifecycle—signal ingest

shield-ingest is the busiest function on the platform. This is the order its handler actually runs in; the enforcement order matters, because each step can end the request.

Phase 1, before any database call. Preflight and liveness answers first (OPTIONS, and GET/HEAD for the status probe). Then the edge-lock check, so a request that skipped the Cloudflare worker is refused. Then a per-IP in-memory damper, the organization id from the path or the x-pb-tenant-id header, a size-capped body read, JSON.parse, the Zod schema, and subject_ref validation. A malformed payload is refused here, with no round trip spent on it.

Phase 2, one database round trip. loadIngestContext calls the shield_ingest_context function, which returns the organization row, the API-key row matched by digest, the matching active playbook and the distributed rate-limit counter together, each section carrying its own error so a playbook failure cannot affect the rate-limit verdict. If that function is missing the module falls back to the four separate reads it replaced, loudly logged.

Phase 3, the checks, in this order. Organization lookup failure answers 503 (x-pb-reason: tenant-lookup-failed), an unknown organization 404, a quarantined one 403. The effective plan is derived from the purchased plan and the billing status, the rate-limit verdict is computed from the counter already in hand, then authentication runs: test mode verifies the session JWT and is_tenant_admin, otherwise the API key row must exist and carry the ingest scope. A x-pb-tenant-id that disagrees with the path is 403. An x-pb-signature header is verified whenever it is present, and required when the organization has turned on signed ingest.

Phase 4, the ceilings. Sandbox keys and test-mode requests are never metered and are bounded by their own rolling allowance of 1,000 signals per 30 days. A Starter organization is held to its monthly signal allotment, reserved one signal at a time in the database so that concurrent requests at the last slot admit exactly one; a paid organization whose subscription is unpaid falls back to the same allotment and answers 402 past it.

Phase 5, the decision. The identifier is keyed (a supplied phone_hash is keyed again as its own digest kind), the playbook is already resolved, and the decision is made. Everything above this point is PasskeyBridge's own work; everything below is either outbound I/O to systems it does not control or the persistence tail. The phase offsets are emitted as one log line per request, keyed by correlation id, carrying durations only.

Phase 6, response and persistence. A hard signal revokes delegates and A2A negotiations inline. Playbook actions then run in parallel, each raced against an 8 s timeout, and outbound actions are simulated for unmetered traffic. A credential_sha1 triggers the breach-corpus range check. The event row is inserted first; only if that insert succeeds are the usage counter and the audit row written, so a signal can never be billed without being stored. The usage counter is keyed on the event id, so the one retry it gets cannot count a signal twice, and a second failure writes a usage.increment_failed audit row rather than under-billing silently. With an idempotency key, the claim sits between Phase 3 and Phase 4 and the outcome is stored last, so a retry replays the answer or resumes from the event insert.

Measured decision latency (production, p50): sub-100ms on a live key. Recorded per request as shield_events.decision_ms. A sandbox (pb_test_) key reads higher, because it pays one extra round trip for the unmetered-traffic ceiling.

Latency budget breakdown

Decision latency is measured rather than budgeted. It is recorded per request as shield_events.decision_ms and covers PasskeyBridge's own work only: authenticate, resolve the organization, keyed-hash the identifier, match a playbook, decide. It excludes time spent waiting on systems we do not control, and it excludes the outbound playbook actions, which are reported separately as response_ms.

Where the time goes, measured on the production edge:

PhaseCostNote
JSON parse + Zod validationLocal, a few millisecondsCPU-only, no DB dependency
Keyed IP digest (HMAC-SHA-256)Local, a few millisecondsWeb Crypto
shield_ingest_context (tenant, API key, playbook, rate counter)One database round trip, and nearly all of the pathDominated by connection setup rather than query time
Phone keyed HMAC-SHA-256Local, under a millisecondWeb Crypto
Playbook matchFreeAlready resolved by the single call
Total (p50)sub-100ms on a live key

So the decision path is one database round trip plus a few milliseconds of local work, and nothing after that round trip is measurable. The round trip is dominated by connection setup rather than query time, and it is paid on every request because the edge isolate does not persist between them.

A sandbox (pb_test_) key adds one further round trip for the unmetered-traffic ceiling, which is why probe runs read well above the live path and are only ever compared against other probe runs.

One decision-path latency figure appears on these pages, and it is the one above. Other millisecond values in the docs describe something else and say so: the per-action ceiling for outbound playbook actions, the jitter penalty in the trust model, the latency_bucket field. The per-phase values behind the bound, the run they came from and its date live in scripts/claims/marketing-claims.json, so a re-measurement updates one file rather than a page of prose.

What keeps the path short:

TechniqueEffect
Validation before authenticationA malformed payload is refused with no database round trip
One combined context callThe organization row, key row, playbook and rate counter arrive together instead of queueing on one connection
Rate-limit verdict computed from a counter already in handThe limit is applied as a decision rather than a second round trip
Revocation ahead of outbound actionsA hard signal's revocation never waits on third-party I/O
Fire-and-forget tailsTrust re-evaluation, the intelligence worker and last_used_at never block the response
Narrow column listsresolveTenantById selects the 14 columns callers read

Cascade revocation mechanics

Two modules govern revocation: the classifier, which is a pure function, and the orchestrator, which executes the branches.

Classification maps a signal type to one of three classes with no configuration, no thresholds and no model:

ClassSignal typesResponse
Hardsim_swap, sim_swap_detected, port_out, number_port, number_porting, device_compromise, ss7_intercept, account_takeover, scope_poisoningImmediate revocation
Softvelocity_anomaly, behavioral_anomaly, geo_anomaly, credential_leak, credential_stuffing_attempt, suspicious_login, failed_verification, device_change, unusual_device, number_recycleAsynchronous trust re-evaluation
UnknownAny other stringPlaybook actions only

The four branches fire together through Promise.all, so the cascade costs the slowest branch rather than the sum:

BranchTableMutation
Agent delegatesshield_agent_delegatesis_active false, trust_score 0, narrowing_reason set to the triggering signal
A2A negotiationsshield_a2a_negotiationsstatus revoked, trust_state auto_revoked, trust coefficient and transaction limits zeroed, scopes emptied
Shadow identitiesshield_shadow_identitiesis_active false, revoked_at stamped
BLAST sessionsshield_blast_sessionstorn_down true, torn_down_at stamped

Counts come from the exact row count of each update rather than the length of the returned array, because PostgREST caps returned rows near a thousand while the update itself touches every matching row. A branch that errors is logged, listed in errors, and does not stop the others.

Who can run it. shield-cascade requires an organization-admin session (the admin scope, which no provisionable API key carries) and the Enterprise plan. An inbound CAEP event that maps to a hard signal runs the same orchestrator inside shield-sse-caep. Ingest revokes only the first two branches.

Dry run. cascade_dry_run counts what would be revoked and mutates nothing:

{
  "signal_type": "sim_swap",
  "signal_class": "hard",
  "delegates_revoked": 3,
  "negotiations_revoked": 1,
  "shadows_frozen": 0,
  "tunnels_torn_down": 2,
  "cascade_latency_ms": 11,
  "correlation_id": "pb-19a2f3c4d5e-7b3c9f02",
  "dry_run": true
}

Agent trust scoring architecture

_shared/agent-trust-engine.ts holds no I/O. The calling function fetches the delegate's recent activity, passes it in, and applies the returned decision.

PropertyValue
Range0.000 to 1.000
Score on delegation1.0
InputThe delegate's recent activity rows over a sliding window
Triggered byA soft signal on the organization, a periodic evaluation, or an explicit re-assessment

Detectors and their weights. Each weight is scaled by how far past the threshold the behavior is, so a marginal breach costs less than a gross one. Since 2026-09-18 every detector except extended_inactivity reads only activity newer than the delegate's trust_updated_at, the watermark left by the last evaluation, so one row is charged once however many evaluations follow it.

DetectorFires whenWeight
scope_violationAny activity row recorded a scope violationup to 0.35
velocity_spikeMore than 50 actions in a 5-minute windowup to 0.30
error_burstMore than 10 failed actions in a 2-minute windowup to 0.25
ip_rotationMore than 5 distinct IP digests in a 10-minute windowup to 0.20
extended_inactivityA burst after more than 24 hours of silence0.15
ai_behavioralThe intelligence worker returned a deltaup to 0.30, either direction
normal_usageNo negative detector fired and there was activity+0.05, slow recovery

off_hours exists as a signal type in the module's type union and no detector produces it today.

What the score does. The per-delegate revocation floor is checked first, because an operator may set it above the default of 0.2 (the wizard allows up to 0.5):

ScoreActionResulting scopes
Below the floor (0.2 by default)recommend_revocationEmpty
At least 0.7noneUnchanged, and narrowed scopes are restored if trust recovered
At least 0.4narrow_to_readThe read scopes present on the delegate: ingest:read, events:read, playbook:read, intelligence:read, verify, with verify always kept
At least 0.2narrow_to_verifyverify only

The original list is preserved in original_scopes, so sustained normal behavior above 0.7 restores it without re-delegating. A hard signal bypasses all of this and zeroes the score.

Multi-tenant isolation

Every organization-scoped table carries a tenant_id. Three mechanisms keep the boundary, and they do not all apply to the same traffic.

Row-level security governs the dashboard. A signed-in user reaches the database through PostgREST with their own JWT, and RLS policies restrict every row to organizations they belong to, resolved through shield_tenant_members. A bug in the dashboard cannot read another organization's rows, because the database refuses them.

Code-level scoping governs the API. An API-key request is served by an edge function holding the service-role key, which bypasses RLS by design. The boundary there is the code: the organization id comes from the path or header, every query filters on it explicitly, and the API key itself is matched with tenant_id in the same WHERE clause, so a valid key for one organization cannot authenticate against another. This is why new write paths are read by hand rather than trusted to the policy layer.

Key separation:

MaterialHow it is isolated
API keysStored as a SHA-256 digest with the organization id; lookup matches both
Credential signing keysA per-organization ECDSA key pair in shield_tenant_keys, P-256 or P-384 by tier, with the private key AES-256-GCM encrypted. A credential issued by one organization does not verify under another's key. Rotation keeps the previous key usable for 24 hours
Post-quantum keysDerived per organization from the platform key by domain-separated HMAC-SHA-256 with the organization id in the separator, so no additional key storage is needed
Webhook signing secretsPer organization, encrypted at rest, with the previous secret accepted for 24 hours after a rotation
BLAST session keysPer session, on a row that carries the organization id
Encrypted payloads (vault, shadow proxy, Slack URLs, raw inbound SETs)One platform key, SHIELD_ENCRYPTION_KEY. There is no per-organization encryption key for these, so their isolation is the tenant_id on the row and the checks above it

Provisioning. An organization is created by the handle_new_user database trigger when the account is created, named from the sign-up form (or "My Organization" when that field is left empty), with the signing-up user inserted as its admin. The setup wizard creates one only when the account has none.

Function inventory

The platform deploys well over a hundred edge functions. What matters for an integrator is the smaller set that is reachable from outside, because the Cloudflare worker's allowlist is what decides that.

Reachable at `https://api.passkeybridge.io/v1/` plus the name:

DomainFunctions
Signals and responseshield-ingest, shield-cascade, shield-predict, shield-carrier-lookup, shield-cross-reference
Credentialsshield-vc-issue, shield-vc-verify, shield-vc-present, shield-vc-status, shield-vc-oid4vci, shield-did-resolve
Agentsshield-agent-delegate, shield-agent-trust, shield-a2a-handshake
Binding and proofsshield-dpop, shield-spatial-bind, shield-cached-proof, shield-shadow-proxy, shield-blast
Provisioning and eventsshield-scim, shield-okta-hooks, shield-sse-caep, shield-passkey-rp
Integrityshield-supply-chain, shield-sdk-attestation, shield-entropy-receipt, shield-chain-checkpoint
Unauthenticated utilitiesshield-health, shield-ntp, openapi-spec, shield-client-errors, /v1/_debug/echo
Provider callbacksstripe-webhook

Console-only. Everything else is invoked from the dashboard with a signed-in session and answers 404 at the public host. That includes shield-admin-query and shield-admin-mutations (API key provisioning, playbook toggles, callback URL and signing-secret rotation, Slack destinations), shield-dsar, shield-pii-migrate, shield-breakglass, shield-support, shield-compliance-report, the identity-posture scanners, and the cron workers such as shield-webhook-worker, shield-sse-deliver, shield-intelligence-worker and shield-reattest.

A name outside the allowlist answers 404 with x-pb-reason: unknown-function at the edge, without reaching the function.

Data flow diagrams

One ingest request, end to end:

Client or carrier webhook
  |
  v  POST https://api.passkeybridge.io/v1/shield-ingest
Cloudflare worker            allowlist check, forwards allowed headers,
  |                          injects the edge secret
  v
shield-ingest
  |- validate (Zod)                   -> 422 on a malformed body
  |- resolve org + key + playbook + rate counter   (one round trip)
  |     -> 503 lookup failed / 404 unknown org / 403 suspended / 429 over limit
  |- authenticate                     -> 401 or 403
  |- verify x-pb-signature if present -> 401
  |- unmetered and monthly ceilings   -> 429 or 402
  |- keyed hash of the identifier
  |- DECISION  (decision_ms recorded here)
  |
  |- hard signal  -> revoke delegates + A2A negotiations, audit row
  |- soft signal  -> fire-and-forget shield-agent-trust (debounced 30 s)
  |
  |- playbook actions in parallel, 8 s each
  |     webhook_callback -> one inline attempt, retries handed to the queue
  |     slack_webhook    -> reference resolved and decrypted, then posted
  |     email_alert      -> scope resolved to member addresses, daily cap
  |     internal actions -> rows updated on this organization only
  |
  |- insert shield_events
  |- then usage counter + audit row in parallel
  |- Enterprise and live traffic only: fire-and-forget intelligence worker
  v
200 {status, result, actions_count, actions_failed_count, latency_ms, correlation_id}

Correlation ids. Each request generates an id of the form pb- plus a hex timestamp plus 8 hex characters, and carries it onward in the x-pb-correlation-id header to shield-agent-trust and the intelligence worker, and into the stored event metadata and the audit row. Searching the audit log for one id reconstructs everything a single signal caused. The same generator stamps cascade runs and inbound CAEP dispatches.

Related from the blog