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.
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?.
| Section | Focus |
|---|---|
| Three-pillar interaction model | What each pillar produces and which other pillar consumes it |
| Shared module architecture | The modules every edge function imports, and who imports them |
| Request lifecycle | The ordered steps of one ingest request |
| Latency budget breakdown | Where the decision path spends its time |
| Cascade revocation mechanics | Classification, the four branches, and dry-run |
| Multi-tenant isolation | Row-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.
| Producer | Artifact | Consumer | Automatic today |
|---|---|---|---|
| I Network attestation | Hard signal (sim_swap, account_takeover, ...) | Agentic layer | Yes. Inside the ingest request, active delegates are deactivated with trust_score = 0 and active A2A negotiations move to revoked |
| I Network attestation | Soft signal (velocity_anomaly, ...) | Agent trust engine | Yes, asynchronously. A fire-and-forget call to shield-agent-trust, debounced to one per organization and signal type per 30 s |
| I Network attestation | Hard signal | II Credentials, III Passkeys | No. Credential suspension and passkey invalidation happen when a playbook action or an API call asks for them |
| II Credentials | Verified credential | Cross-reference engine | No. shield-cross-reference binds a subject on request |
| III Passkeys | Registered credential | Cross-reference engine | No. 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 parallelThe 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.
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:
| Phase | Cost | Note |
|---|---|---|
| JSON parse + Zod validation | Local, a few milliseconds | CPU-only, no DB dependency |
| Keyed IP digest (HMAC-SHA-256) | Local, a few milliseconds | Web Crypto |
shield_ingest_context (tenant, API key, playbook, rate counter) | One database round trip, and nearly all of the path | Dominated by connection setup rather than query time |
| Phone keyed HMAC-SHA-256 | Local, under a millisecond | Web Crypto |
| Playbook match | Free | Already 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:
| Technique | Effect |
|---|---|
| Validation before authentication | A malformed payload is refused with no database round trip |
| One combined context call | The 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 hand | The limit is applied as a decision rather than a second round trip |
| Revocation ahead of outbound actions | A hard signal's revocation never waits on third-party I/O |
| Fire-and-forget tails | Trust re-evaluation, the intelligence worker and last_used_at never block the response |
| Narrow column lists | resolveTenantById 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:
| Class | Signal types | Response |
|---|---|---|
| Hard | sim_swap, sim_swap_detected, port_out, number_port, number_porting, device_compromise, ss7_intercept, account_takeover, scope_poisoning | Immediate revocation |
| Soft | velocity_anomaly, behavioral_anomaly, geo_anomaly, credential_leak, credential_stuffing_attempt, suspicious_login, failed_verification, device_change, unusual_device, number_recycle | Asynchronous trust re-evaluation |
| Unknown | Any other string | Playbook actions only |
The four branches fire together through Promise.all, so the cascade costs the slowest branch rather than the sum:
| Branch | Table | Mutation |
|---|---|---|
| Agent delegates | shield_agent_delegates | is_active false, trust_score 0, narrowing_reason set to the triggering signal |
| A2A negotiations | shield_a2a_negotiations | status revoked, trust_state auto_revoked, trust coefficient and transaction limits zeroed, scopes emptied |
| Shadow identities | shield_shadow_identities | is_active false, revoked_at stamped |
| BLAST sessions | shield_blast_sessions | torn_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.
| Property | Value |
|---|---|
| Range | 0.000 to 1.000 |
| Score on delegation | 1.0 |
| Input | The delegate's recent activity rows over a sliding window |
| Triggered by | A 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.
| Detector | Fires when | Weight |
|---|---|---|
scope_violation | Any activity row recorded a scope violation | up to 0.35 |
velocity_spike | More than 50 actions in a 5-minute window | up to 0.30 |
error_burst | More than 10 failed actions in a 2-minute window | up to 0.25 |
ip_rotation | More than 5 distinct IP digests in a 10-minute window | up to 0.20 |
extended_inactivity | A burst after more than 24 hours of silence | 0.15 |
ai_behavioral | The intelligence worker returned a delta | up to 0.30, either direction |
normal_usage | No 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):
| Score | Action | Resulting scopes |
|---|---|---|
| Below the floor (0.2 by default) | recommend_revocation | Empty |
| At least 0.7 | none | Unchanged, and narrowed scopes are restored if trust recovered |
| At least 0.4 | narrow_to_read | The read scopes present on the delegate: ingest:read, events:read, playbook:read, intelligence:read, verify, with verify always kept |
| At least 0.2 | narrow_to_verify | verify 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:
| Material | How it is isolated |
|---|---|
| API keys | Stored as a SHA-256 digest with the organization id; lookup matches both |
| Credential signing keys | A 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 keys | Derived 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 secrets | Per organization, encrypted at rest, with the previous secret accepted for 24 hours after a rotation |
| BLAST session keys | Per 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:
| Domain | Functions |
|---|---|
| Signals and response | shield-ingest, shield-cascade, shield-predict, shield-carrier-lookup, shield-cross-reference |
| Credentials | shield-vc-issue, shield-vc-verify, shield-vc-present, shield-vc-status, shield-vc-oid4vci, shield-did-resolve |
| Agents | shield-agent-delegate, shield-agent-trust, shield-a2a-handshake |
| Binding and proofs | shield-dpop, shield-spatial-bind, shield-cached-proof, shield-shadow-proxy, shield-blast |
| Provisioning and events | shield-scim, shield-okta-hooks, shield-sse-caep, shield-passkey-rp |
| Integrity | shield-supply-chain, shield-sdk-attestation, shield-entropy-receipt, shield-chain-checkpoint |
| Unauthenticated utilities | shield-health, shield-ntp, openapi-spec, shield-client-errors, /v1/_debug/echo |
| Provider callbacks | stripe-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.