Graine AI
Enterprise

Security

How Graine protects customer data — the encryption model, the tenancy boundary, scope-based access control, webhook integrity, and a tamper-evident audit trail you can verify yourself through the API.

This page describes the security architecture of the Graine platform: the mechanisms, where each one is enforced, and what a reviewer can verify independently. Where a control is provisioned per deployment or enabled per agent, that is stated in its own description.

The architecture of protection

Three properties define the shape of the platform's security model. Everything in the sections below is an application of one of them.

Encryption is layered, and the layers are independent. Transport security protects data in motion: TLS is terminated at the edge with a managed certificate, plain HTTP is redirected with a 301, live call media runs over wss://, and outbound calls to model, speech and telephony providers are HTTPS. Underneath that, selected fields are encrypted at rest with AES-256-GCM before they are written to the database — so a database dump, a backup copy or a support read yields ciphertext for those fields regardless of how the data arrived.

Tenancy is resolved from the credential, never from the request. A single function derives the organization from the authenticated API key. An organization_id parameter is accepted for API symmetry, but any value other than the key's own organization is a hard 403 — never a silent override. The boundary is one function rather than a convention repeated per handler, which means it can be audited in one place and cannot drift handler by handler.

Key custody is explicit, and it is a deployment property. In the base configuration, encryption keys are held by the deployment as operator-supplied environment configuration, with a two-key read path for rotation: the current key is tried first, then the previous key, so a rotation does not require a backfill. Where the envelope layer is provisioned, that single deployment key is replaced by a per-organization data key wrapped by AWS KMS — optionally under a customer master key held in your own AWS account, which you can revoke. Screening-call transcripts additionally use a key derived per owner rather than one key for the whole database. Because custody is set per deployment, scope it explicitly before contract; the answer for the environment you will run against is given in writing.

Data protection

Field-level encryption at rest

PropertyValue
AlgorithmAES-256-GCM — authenticated encryption (AEAD)
IV96-bit, randomly generated per value, never reused
Stored formatenc:v1: + base64 of iv || ciphertext || tag, or enc:v2:<scheme>:<key_id>: + the same, under the envelope layer
Key custodyOperator-supplied deployment configuration, or a per-organization KMS-wrapped data key
RotationTwo-key read — current key first, then the previous key. Under the envelope layer, the key id travels in the ciphertext

Covered fields:

  • Contact phone numbers and originating numbers on call records and on batch contacts.
  • Webhook signing secrets.
  • Every custom webhook header value — not a subset judged to "look sensitive". A heuristic over customer-chosen header names cannot be complete (X-Client-Token, Cookie, X-Signature are all plausible), so the rule is uniform. Your own Authorization: Bearer … for your endpoint is protected against a database dump, a support read or a leaked backup.

Legacy plaintext values decrypt transparently, so enabling a key does not require a backfill — and equally does not encrypt what is already stored.

This control is provisioned per deployment. With a key configured, the covered fields are written as ciphertext. With no key configured, the write succeeds in plaintext with a log warning rather than failing — the design prioritises not losing a record. Ask us to confirm the key state of the environment you will be running against; it is a one-line answer and we will give it in writing.

A related guarantee runs on read: a secret or header value that cannot be decrypted is dropped rather than sent. The platform will not ship a enc:v1:… string as your Authorization header, and will not sign with an unreadable key — the subscription is marked unavailable and the delivery ledger records signed=False, so the condition is visible rather than silent.

Per-tenant envelope encryption

Where it is provisioned, field encryption runs under a three-level key hierarchy rather than one deployment key:

AWS KMS master key  (the platform's, or your own account's under BYOK)
   │  wraps — GenerateDataKey / Decrypt, under an encryption context

per-organization data key (AES-256), one per organization, scheme and generation
   │  encrypts — AES-256-GCM, in process, no network call

field values — contact and originating numbers, webhook secrets, header values

The master key never sees field data; it only ever wraps 32 bytes. That is the point of the arrangement: the KMS boundary sits at the wrapped key, so the read path stays local AES-GCM and a KMS rate limit cannot become indistinguishable from a call-processing outage. Unwrapped data keys are cached in process with a 15-minute TTL by default, which is also the fleet-wide bound on how long a destroyed key can still be used.

Three properties are worth a reviewer's attention:

Decryption is self-addressing. The stored form is enc:v2:<scheme>:<key_id>:<base64>, and everything before the final colon is fed to AES-GCM as additional authenticated data. The key is read out of the ciphertext rather than inferred from a caller who may not know the organization, so a wrong or empty organization id cannot produce a plausible-looking decrypt, and a value copied between tenants fails the authentication tag instead of returning another organization's phone number.

Customer-managed keys (BYOK). An organization can be configured against a master key in its own AWS account; the platform's wrap and unwrap calls then name that key explicitly, so withdrawing the grant returns AccessDenied and stops every future unwrap — bounded by the cache TTL above, not indefinite. Adoption is a control-plane operation: the wrapped data key is re-wrapped from one master key to the other after a round-trip probe, and no field value is rewritten. The platform records which master key each organization's data key sits under.

Crypto-shredding. Destroying an organization's wrapped data key makes its ciphertext unreadable without touching a single record. It is deliberately hard to invoke by accident — four gates, all required: a typed confirmation matching that organization's key handle, a closed account or two distinct human actors, an arming at least 24 hours old and not expired, and a preflight that must find zero plaintext copies of the fields being shredded. That last gate is what makes the routine honest rather than decorative: destroying a key while a plaintext mirror of the same number sits in another collection deletes nothing and produces a false compliance claim, so a preflight failure is a refusal, not a warning. Every gate, arming and refusal — including the shred someone tried and could not complete — is written to the audit chain described below.

The irreversibility claim is stated precisely, because the two dates are not the same: the data is immediately unreadable in production, and permanently unrecoverable once the database backup retention window has elapsed, since a point-in-time restore would resurrect the wrapped key. The routine returns that second date rather than leaving it to be guessed. The key record itself survives as a tombstone, which is what lets a shredded read return empty at INFO instead of being indistinguishable from a KMS outage.

This layer is provisioned per deployment and is off by default. With no master key configured, it is inert — no key store read, no cache, no background task — and writes use the deployment key described above. The earlier enc:v1: format is permanent, not a migration phase: values written before the envelope layer existed are read by a decoder that is never removed, pinned by a test carrying frozen ciphertext.

Per-owner transcript encryption

Personal screening-call transcripts and summaries are encrypted under a key derived per owner: HKDF-SHA256 from a server master key salted with the owner's identifier, with that identifier used as the AES-GCM additional authenticated data. One owner's derived key cannot decrypt another owner's record, and the ciphertext is cryptographically bound to its owner. Covered fields are the transcript, its original and English renderings, and both summary forms.

This is at-rest and owner-gated, not zero-knowledge — the server holds the master key, because it performs translation and summarisation. It is enabled by setting the master key on the deployment; unset, those fields are stored in plaintext and existing plaintext records continue to read normally.

Transcript PII redaction

Transcript redaction can be enabled per agent. When it is, emails, payment card numbers (13–19 digits with optional separators), US Social Security numbers, Indian Aadhaar and PAN identifiers, clustered phone-number digits and IBAN-shaped account numbers are stripped before the transcript is written to storage — this is not a read-time mask over stored plaintext. Patterns are applied longest-match-first so no fragments survive, and both transcript shapes are walked: a plain string, and a structured turn-by-turn list.

Redaction is regular-expression matching. It reduces exposure of the identifier classes above; it does not perform named-entity recognition, so treat personal names and free-text identifiers as retained. The per-agent setting is cached for five minutes.

Access control

One credential on the public API

The /v2 API accepts exactly one credential: an API key with a gat_ prefix, presented as a bearer token.

curl -sS https://api.graine.ai/v2/user/me \
  -H "Authorization: Bearer gat_YOUR_API_KEY"

The credential is inspected before any network call is made, so a browser session token presented to /v2 is rejected outright rather than exchanged for access:

{
  "error": 1101,
  "message": "A browser session token is not a public API key. Authenticate with Authorization: Bearer gat_..."
}

A leaked dashboard session can never drive the partner API. Keys are validated against AWS Secrets Manager rather than the application database and cached in-process for 30 seconds, so a revocation propagates within that window. A key record carrying no organization is rejected rather than allowed to run an unscoped query.

Errors follow one envelope across the whole surface:

CodeHTTPMeaning
1100401Missing, unknown, or inactive API key
1101401A browser session token was presented instead of an API key
1102403Valid key, but not for this organization, or lacking a required scope
1200404No such resource in this organization
1300429Request rate exceeded, or concurrency exhausted

Scopes on every mutating endpoint

Eight scopes:

agents:read   agents:write   calls:read   calls:write
batches:write  webhooks:write  keys:write   audit:read

They are enforced on every mutating /v2 endpoint — every POST, PATCH, PUT and DELETE on the surface — through a single chokepoint. No /v2 route depends on the authenticator directly; every route reaches it through the rate limiter, and the scope guard composes on top of that limiter, so one dependency authenticates, meters and authorises. A policy change in one file covers the whole surface with no per-route edits.

That claim cannot drift into fiction, and it is not maintained by hand: a test enumerates the live router at test time and fails if any mutating route is absent from the published scope table. A route added without a scope does not ship.

A denial returns 1102/403 and writes an audit row (scope.denied), so "the partner's integration broke after the deploy" is a query, not an investigation. POST /v2/api-keys takes an explicit scopes array and can never grant a scope the minting key does not itself hold. GET /v2/scopes publishes the taxonomy and what the calling key holds. Wildcards resource:* and * are honoured. Full rules are in Authentication.

Two properties worth knowing when you plan a key policy:

Keys can be minted with an explicit scope set. Keys issued before scopes existed carry no scope field and remain unrestricted — an absent policy is honoured as "everything", deliberately, because reinterpreting it as "deny" would have broken live integrations. An explicitly empty list [] grants nothing, and the two are distinguished by an explicit membership test rather than a truthiness check. Every audit row stamps whether the key's authority was explicit or inherited, which makes "how many keys still hold implicit full access" a query with an answer. Rotate legacy keys onto explicit scopes; nothing else narrows them.

keys:write is never granted implicitly. It is deny-by-default and deliberately absent from the compatibility grant, so no key in circulation can mint or revoke another key without that scope being set explicitly.

The dashboard

The dashboard authenticates with a Stytch B2B session token, validated against Stytch on each request. Two consequences for a review: the identity provider is a dependency on the authenticated request path, and session validation is not cached. Dashboard authorization is a single administrative boolean derived from the member's platform role — the fine-grained model above is the /v2 API's.

Service-to-service and administrative endpoints are protected by shared secrets compared in constant time; a blank configured secret never matches any presented value.

Rate limiting and concurrency governance

Rate limiting is per organization, not per key, so an organization cannot multiply its quota by minting more keys. The implementation is a two-bucket weighted sliding window in Redis, executed as a single Lua script — one round-trip with no read-modify-write in application code, so two concurrent requests cannot both observe headroom.

BucketLimitApplies to
default1,000 / minuteEverything not listed below
calls.create500 / minuteCreating calls, batches and batch schedules
executions.list500 / minuteListing calls, executions and usage

Every /v2 response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a denial adds Retry-After with error 1300. It is on for every /v2 route — there are no deliberately unmetered routes.

Separately, a four-level concurrency governor bounds live call capacity: global, per organization and per agent are Redis-backed with an atomic Lua check-and-increment, so two workers cannot both observe a free slot and both acquire it; per batch is a local semaphore. Slots carry a two-hour safety TTL and decrements are floored at zero. A reconciler corrects drift with a deliberate asymmetry — upward corrections apply immediately, because they can only briefly delay a dial, while downward corrections within a small margin must be observed twice, because a racy downward write under-counts and would admit dials beyond the account's capacity.

Tenant isolation

Every /v2 route resolves its organization through the one function described at the top of this page, and the resolver is called on every handler in the public package. Three properties make the boundary hold:

A cross-tenant identifier answers 404, never 403. An identifier belonging to another organization is indistinguishable from one that does not exist, so ids cannot be probed for existence. Cross-organization reads are refused with "not found" and logged as blocked.

The organization is a query term, not a post-filter. Primary reads carry the organization inside the database query itself — {"execution_id": …, "organization_id": org_id} — rather than fetching a document and checking it afterwards.

Business and consumer records are separated by a second filter. The two products share one call-records collection and the consumer product runs under a real organization that also places business calls, so an organization filter alone would not separate them. Every dashboard- and API-facing read applies the organization scope and a surface discriminator that only the consumer path ever writes. Neither is sufficient alone; both are always applied.

This is not configurable and there is no bypass flag.

Use /v2 for anything an external party touches

The legacy /api/v1 surface predates the single-chokepoint design and does not carry the same guarantee: on that surface an organization_id parameter is used without a membership check between authentication and the query. /v2 is the supported public API and is where the boundary is enforced. Do not expose /api/v1 to principals you would not trust with the whole tenant set.

Webhook integrity

Signatures over the exact bytes sent

Each delivery carries X-Graine-Signature: t=<unix>,v1=<hex> alongside X-Graine-Timestamp, where the signature is HMAC-SHA256(secret, "<unix_ts>." + body) in lowercase hex.

The signed bytes are the bytes on the wire. The payload is serialised once and never re-serialised, so your verification can never fail on JSON key ordering or float formatting — the failure mode integrators most often get burned by elsewhere. The signing function refuses anything but raw bytes.

The timestamp is inside the signed material, not merely alongside it, so a captured POST cannot be replayed later under a fresh header: recompute over the t you received and reject a stale one. Graine is the sender here; the staleness threshold is yours to choose and enforce at your endpoint.

Custom headers you configure are applied after the platform's own and filtered: anything named content-type, host or content-length is dropped, so a custom header cannot misstate the body length or the content type. A CR or LF in a header name or value drops that header — a request-splitting and log-forging guard — and the name is escaped before it reaches a log line.

Treat X-Graine-* as reserved. Custom headers are applied last, so one configured with a platform header's name replaces the platform's value — including X-Graine-Signature, which your own verifier depends on. The filter above does not currently reject that name for you. Identify a delivery by X-Graine-Delivery-Id and X-Graine-Attempt rather than by the request's User-Agent, which is not part of the signed material and is not a stable contract.

Outbound request vetting that defeats DNS rebinding

Before a callback URL is used, the host is resolved off the event loop and every resolved address is checked. An address is blocked when it is not globally routable, or is loopback, link-local, private, reserved, multicast or unspecified; IPv4-mapped and 6to4 forms are unwrapped first, and blocked host and suffix lists run ahead of the address check.

The "not globally routable" test leads deliberately, and for a concrete reason: on the Python version this fleet runs, 100.64.0.0/10 (RFC 6598 shared address space — the standard secondary-CIDR pod range on EKS) is neither private nor reserved, only non-global. Without that test a callback URL pointed at a neighbouring pod would be fetched on the caller's behalf.

Vetting a name is not enough, so the request is pinned to the vetted address. The outbound request URL is rewritten to the vetted IP literal while the original hostname is kept in the Host header and in TLS SNI and certificate verification. Your virtual hosting and your certificate work exactly as before, but a second DNS answer cannot change where the bytes go. Redirects are not followed — a 3xx is a retry, never a silent hop to an unvetted host.

A DNS failure is deliberately not treated as a block: it surfaces as a retryable dns_error rather than a permanent rejection on your delivery ledger. Logs carry the callback host only — never a full URL (query strings routinely carry tokens), never a header value, never a signing secret. A single environment flag disables the guard for local development; the enforcing behaviour is the default.

No duplicate sends, no lost deliveries

The delivery scheduler runs unleadered across every application process, so a one-shot job can fire once per process. Every attempt is therefore claimed: a Redis SET NX as a fast reject, then an atomic conditional database flip that is the authority — the claim requires the row to still be pending and the stored attempt pointer to be strictly lower than the attempt being claimed.

Strictly lower makes it a monotonic barrier, not an equality test: it rejects a replayed lower attempt and a re-claim of the current one, and can never rewind the ladder. That distinction is not theoretical — an earlier inequality condition allowed an at-least-once message replay to re-send attempt 1 and rewind the counter so attempt 2 replayed too. It is fixed and covered by a regression test, which is the kind of evidence worth more than an assurance.

Retries are scheduled as jobs persisted to PostgreSQL, not in-process sleeps, so a redeploy mid-ladder does not lose the webhook — and job arguments are JSON scalars only, so your payload never enters the job store. A second layer sweeps the delivery ledger every 120 seconds and re-drives any pending row whose next attempt is more than 60 seconds stale. The worst case is a late delivery, never a lost one.

The ladder is 4 attempts at +0s, +10s, +60s and +120s from the first attempt, with a 30-second timeout each. Every attempt is a durable ledger row carrying an error class — timeout, TLS error, DNS error, transport error, or the HTTP status.

Monitoring and audit

An append-only, hash-chained audit trail

Every state-changing /v2 action writes one row to a per-organization, append-only, hash-chained log. There is no update path anywhere in the module, and the only delete is a bounded retention sweep.

The mechanism, because a reviewer will ask:

  • Each row's hash is SHA-256 over a canonical JSON serialisation (sorted keys, fixed separators) of the hashed field set — which includes the sequence number and the previous row's hash. That is what makes re-linking impossible: deleting a middle row and re-pointing its successor would require rewriting every subsequent hash, and each rewrite is itself a detectable mismatch.
  • Chain position is claimed by compare-and-swap on a small per-organization head document held in a separate collection, and a unique index on (organization_id, seq) makes the invariant structural — two rows can never share a sequence number, so a duplicated append loses the race instead of forking the chain.

GET /v2/audit-logs/verify walks the chain and reports discrepancies by kind:

FindingWhat it means
hash_mismatchA row was edited — an outcome, a resource id, a timestamp
broken_link + missing_recordA middle row was deleted; two independent signals fire, not one
missing_record against the headRows were removed from the newest end — including a total wipe, because the head document lives in another collection and survives the deletion of every row
head_mismatchThe tip was rewritten
unsealed_record / unreadable_recordA row was written or stored malformed

Legitimate retention pruning verifies clean and is distinguishable from tampering: the sweep advances a prune watermark and the surviving chain links to it. There is deliberately no database TTL index, because an unmanaged TTL delete would make every later verification cry wolf.

You can verify independently. Every listed row publishes its own hash and prev_hash, so an exported log can be re-verified outside Graine entirely.

EndpointPurpose
GET /v2/audit-logsList rows for your organization, filtered and paged
GET /v2/audit-logs/{event_id}One row
GET /v2/audit-logs/verifyWalk the chain and report discrepancies

All three require the audit:read scope. Verification is bounded and resumable — 500 rows per call by default, 2,000 maximum; when next_seq returns non-null, pass it back as start_seq.

What a row records

Each row carries who (key id, developer id and name), what (action, resource type, resource id), the outcome (success / denied / failure), the HTTP status and error code, the request origin, and a changes summary — field names, counts and identifiers. The request body is never stored. A 4xx is recorded as denied and a 5xx as failure, because "who was told no" and "what broke" are different questions. Every scope denial is a row, and reads of the audit log itself are audited.

Coverage is every state-changing /v2 route plus control events. Reads of business data are not audit events, dashboard actions are recorded separately from the /v2 trail, and there is no SIEM export today — read the API on a schedule if you need the rows in your own tooling.

Retention is 400 days by default, configurable per organization between 30 and 3,650 days.

One property worth stating precisely

Audit writes are fire-and-forget: a row is durable a few milliseconds after the response, not before it, so appending to the audit log can never slow or fail a customer's call. A process killed mid-flight can therefore lose an in-flight row — and that loss is not silent. The sequence position is claimed before the insert on purpose, so verification reports a missing_record at exactly that position and the write statistics count it.

"We have a complete audit trail" is therefore a query you can run, not a claim you have to take on faith.

Regression and contract guards

The orchestration service carries 1,000+ automated tests across 117 files (1,400+ across the platform). The security-relevant suites are contract guards rather than a substitute for a penetration test — they run without a database, a network or a running app, which is what makes them cheap enough to run on every commit:

SuiteWhat it guards
Audit and scope hardeningEnumerates the live router and fails if any mutating route is missing from the published scope table; asserts every module with a mutating route records what it did; runs the real chain verifier against the total-deletion case
Scope back-compatibilityAbsent versus empty scope policies; the lockout and escalation boundaries
Webhook engineSigning, the retry ladder and the delivery engine
Surface isolationTenant and product separation on shared collections
Concurrency and idempotent dispatchAtomic slot acquisition; duplicate-suppression under concurrent consumers

The stated bar for that suite is "a bug that shipped": each guard exists because the failure it catches reached production once, and each is verified to fail against the original bug before it is committed.

Certifications and attestations

Graine does not currently hold a SOC 2 (Type I or Type II) or ISO 27001 attestation, and no third-party penetration test report is available today.

The controls described above are implemented in the platform and verifiable now: tenant isolation and scope enforcement can be exercised against your own keys, and the audit chain can be verified through GET /v2/audit-logs/verify or re-verified independently from an exported log. In substance they cover the areas an attestation examines — cryptographic controls and key rotation, logical access control and least privilege, tenant separation, integrity of outbound communication, and a tamper-evident record of administrative activity — with implementation detail we will supply in writing on request.

We will complete a security questionnaire, provide architecture detail, or arrange a technical review with the engineers who wrote these controls.

Raising a security question

  • Security questionnaires (CAIQ, VSA, or your own template) — send it to your Graine representative. Answers are given in writing, including where the answer is "not implemented"; nothing is deferred to a sales conversation.
  • Architecture review — we will put the engineers who built the audit chain, the tenancy resolver and the delivery engine on a call with your reviewers.
  • Evidence requests — deployment configuration, the current provider list reachable from your agent configuration, the encryption key state for your environment, and storage access policy are all available as evidence items on request.
  • Reporting a vulnerability — contact your Graine representative directly. Please do not open a public issue.