Jurisdictional Lineage Attestation
Per-request signed claims about the model, region evidence and log jurisdiction. Verification establishes integrity of the recorded claim; it does not independently establish the physical location of inference. Ed25519-signed. Append-only, tamper-evident ledger.
Architecture
Gateway:
┌─ RegionResolver ─→ header evidence / pinned endpoint → ISO-3166 region + evidence tier
├─ KeyStore ───────→ EnsureKeyPair(orgID) → Ed25519 keypair (AES-GCM encrypted)
├─ LedgerStore ────→ Append(attestation, signature, keyID)
└─ EventPublisher ─→ InferenceRegion, ModelOriginCountry, LogStorageRegion,
ProviderEntityJurisdiction, AttestationHash, Signature, KeyID
Loggerd:
└─ Consumer ───────→ Extracts lineage fields from Redis event → request_lineage table
Admin API:
GET /api/lineage/{request_id} → attestation JSON
POST /api/lineage/{request_id}/verify → {valid: bool}
GET /api/organizations/{id}/lineage → recent attestations
Cryptography
- Algorithm: Ed25519 (stdlib
crypto/ed25519) - Hash target: hex SHA-256 of canonical attestation JSON (
json.Marshalof struct) - Key storage: private key AES-GCM encrypted with org encryption key (
enc:prefix) - Determinism: Go marshals struct fields in source order → byte-identical across builds
Evidence tiers (what the region field actually proves)
inference_region in the attestation is graded. The attestation carries the
tier it was resolved at (region_evidence), so a reviewer never has to guess
how strong the claim is:
| Tier | Meaning | Example |
|---|---|---|
endpoint_pinned | The destination endpoint itself encodes the region — the gateway sent the request to a region-specific resource and records that fact | self-hosted vLLM/llama.cpp; Azure OpenAI Japan East; Bedrock ap-northeast-1; any model registered with a non-GLOBAL region |
header_observed | Response headers provide a location signal, which may identify a CDN edge rather than inference compute | cf-ray colo suffix, x-served-by |
provider_attested | The provider explicitly attests the region via the trusted x-yabanin-region opt-in header | provider opt-in integrations |
provider_declared | The vendor does not disclose serving regions; the value is the provider's registered default | global endpoints like api.openai.com |
Honest scope: for global vendor endpoints, no provider discloses the physical
processing location — the attestation records provider_declared rather than
implying knowledge it does not have. Residency packs (fail-closed) let an org
forbid provider_declared-tier models entirely, which is the correct posture
for region-pinned buyers.
Database schema
| Table | Purpose |
|---|---|
request_lineage (baseline 0001_init) | Append-only ledger, tamper-evident (UPDATE/DELETE blocked by trigger) |
provider_jurisdictions (baseline 0001_init) | Provider→country mapping with seed data |
org_lineage_keys (baseline 0001_init) | Per-org Ed25519 keypairs, versioned, revocable |
Configuration
gateway:
lineage_enabled: true # PB1.6 — gate all lineage functionality
When disabled (false or unset), the gateway skips region resolution and signing
entirely — zero latency overhead.
Relationship to other features
Lineage and residency are complementary, not redundant:
- Lineage records region evidence after the request, with a verifiable signature.
- Residency prevents data from going to the wrong jurisdiction (before the fact, via policy enforcement). See Residency policy-packs.
Lineage integrates with the following deployment components; their presence alone does not establish legal compliance:
- Multi-region active-active — data pinned to a home region via Postgres Row-Level Security; lineage attests the region per request.
- Air-gap / on-prem — single-tenant deployments with bundled KMS and license/PKI; lineage keys never leave the customer environment.
- Sovereign-model registry —
model_origin_countryis part of the signed attestation, as a recorded model-registry claim. - Admin audit log — mutations are written to
admin_audit_logsas tamper-evident hash-chained rows (wired since AF-10, 2026-10-02: the admin write path appends through the shared protocolauditlog.AppendInTx—pkg/adminstore.InsertAuditLogEntry— with persisted before/after/reason snapshots and an explicithash_version, migration0019; the chain is verified end-to-end against a real Postgres by integration tests, including concurrency and tamper detection). The JIT-access package (pkg/jitaccess) exists in-tree but is not wired into the admin write path; do not present it as shipped. Formal SOC 2 / ISO 27001 control worksheets are internal drafts, not certifications.
Durability & loss-window (design decision)
The gateway commits each attestation through pkg/lineage.AsyncLedger: a
bounded in-memory queue (capacity 1024) drained by a single background
worker that writes in batches (pgx.Batch, flush every 100 ms or
100 rows). Because the ledger is evidence (audit/compliance), the design
contract is lossless-by-fallback: the queue never drops, and any batch
failure degrades to synchronous single-row appends. This section records the
failure matrix and the outbox decision that bounds (or removes) the in-memory
window.
Write path (today)
request ──sign──► AsyncLedger.Append
│ queue has room? ──yes──► chan (≤1024) ──► worker ──► AppendBatch
│ no └─batch err─► sync Append (per row)
└──no (queue full)────────────► sync Append (in caller)
graceful shutdown ──► Close(5s) ──► ctx.Done ──► drain queue ──► final flush
Failure matrix
| Failure | Behavior | Loss window | Notes |
|---|---|---|---|
| Graceful stop (SIGTERM) | Close(5s) cancels the worker ctx; the worker drains the queue and does a final flush before exiting | ~0 (bounded by the 5 s drain deadline) | The 5 s budget covers both srv.Shutdown and the drain; a DB that is up at shutdown writes everything queued. |
| Hard kill (SIGKILL / OOM / power) | No drain runs; the in-memory queue is gone | up to 1024 attestations (the whole queue) + at most one in-flight batch | The only true-loss case. Bounded by queue capacity, not by time. |
| DB outage (Postgres down) | Batch flush fails → each item falls back to a synchronous Append, which also fails while the DB is down | 0 lost, N unrecorded — items fail fast and are logged as lineage append lost; they are not re-queued | Degrades to "best effort while DB is down": the request succeeds (lineage is best-effort per-request), the attestation is logged as lost. When the DB returns, new appends succeed again. |
| Queue full (consumer slower than producers) | Append falls through to a synchronous single-row write in the request path | 0 | Adds request latency under back-pressure instead of dropping; the ledger is evidence, so we never drop. |
| Batch write error (DB up, transient) | Retry each item synchronously | 0 (per-row retry; a row that still fails is logged lineage append lost) | Converts a batch failure into per-row attempts so one bad row doesn't take the batch with it. |
| Worker crash / panic | Worker goroutine exits; subsequent Append calls find the queue filling and fall back to synchronous writes | 0 (after the queue fills) | The default branch of Append is the safety net: even with no live worker, writes go straight to the DB. |
Key property: the loss window is memory-bounded, not time-bounded. The worst case is a hard kill of a process that has a full 1024-deep queue; in all DB-side failure modes the ledger either writes synchronously or fails the attestation loudly (logged), never silently dropping.
Decision: outbox table + consumer (adopted) vs. synchronous append
We evaluated replacing the in-memory queue with a transactional outbox:
write the attestation row into an outbox table in the same Postgres
transaction as the request log, then have a consumer relay outbox rows into
request_lineage.
| Criterion | In-memory AsyncLedger (today) | Outbox table + consumer |
|---|---|---|
| Hard-kill loss window | up to 1024 attestations | 0 (row is in the same committed txn as the request log) |
| DB-outage behavior | attestation lost (logged) while DB down | cannot happen — if the DB is down, the request-log txn doesn't commit either, so there is no outbox row to lose; consistency preserved |
| Latency on the request path | +0 (async); +1 round-trip only under queue-full back-pressure | +1 INSERT in the request txn (small, same connection) |
| Ordering / at-least-once | at-most-once per process life (queue is volatile) | at-least-once (consumer must dedupe on request_id); needs idempotent consumer |
| Complexity | low (one goroutine, one channel) | higher (outbox schema, consumer, dedupe, backlog monitoring, DLQ) |
| Backlog visibility | queue depth (log-only) | queryable table (count, oldest-age, per-org) |
Chosen: outbox table + consumer. Rationale: the lineage ledger is compliance evidence, and the one mode where the current design loses data — a hard kill with a full queue — is exactly the mode an audit would probe. The outbox removes that window entirely and makes "DB down" a non-issue (no orphaned evidence), at the cost of one in-transaction insert and a dedupe-aware consumer. The in-memory AsyncLedger remains a valid fast path for the steady state; the outbox is the durability backstop.
Loss window with the outbox: zero for both hard-kill and DB-outage. The only residual gap is a request-log transaction that commits but whose outbox→lineage relay has not yet run — that is a delay, not a loss (the row is durable in the outbox and will be relayed; the consumer is idempotent).
Status (shipped): the outbox is implemented (baseline
0001_init,pkg/lineage/outbox.go).AsyncLedger.Appendnow writes the attestation tolineage_outbox— durable once it returns — and a background relay (RelayLoop) moves pending rows intorequest_lineage. The relay is the single writer of chained rows (a batch writer racing it could interleave per-org chain links) and is idempotent onrequest_id(INSERT … ON CONFLICT DO NOTHING, then markrelayed_at). Relay work is bounded per pass (LIMIT 500); a failed row stays pending and is retried; a malformed row is marked relayed and logged loudly rather than wedging the pipeline. DB-outage behavior:Appendreturns an error (no false success). Kill/restart/DB-outage testcontainers tests are gated on Docker.
Out-of-scope / assumptions (inputs to the decision)
- The request log and the outbox row must share one Postgres transaction
(same
*pgx.Tx); the consumer relays outbox →request_lineageand is idempotent onrequest_id(unique key). request_lineagealready has a tamper-evident trigger (UPDATE/DELETE blocked); the outbox table is a separate, mutable staging table and is not part of the signed evidence chain — only the relayedrequest_lineagerow is.- A follow-up work item implements the outbox schema + consumer and flips the gateway to the in-txn write; this section records the decision that gates it.
Tamper-evidence: hash chain + WORM export
There are two independent hash chains in this system. Do not mix them up:
| Chain | Table | Scope | Origin | Status |
|---|---|---|---|---|
| Request lineage | request_lineage | per-org, every inference request | baseline 0001_init | shipped (chain + verify + export) |
| Admin audit log | admin_audit_logs | per-operator, admin mutations | baseline 0001_init; chain protocol wired since AF-10 (0019_audit_snapshots_chain) | shipped (chain written by the admin write path; verify — integration tests) |
They are separate append-only ledgers with separate schemas, separate writers,
and separate verification. This section documents the request-lineage chain
only. The admin_audit_logs table is a different concern (admin action
evidence) with its own chain protocol — see the note above.
How the request-lineage chain works
Each request_lineage row carries two extra columns (baseline 0001_init):
record_hash—sha256(prev_hash || canonical_record), the row's own link hash. A fixed-order,|-delimitedcanonical_recordstring (over the INSERT columns) is the single source of truth shared by the writer and the verifier.prev_hash— the previous row'srecord_hash(NULLfor the chain head).
Because every row embeds the previous row's hash, any edit, insertion, or
deletion in the middle of an org's history breaks every link after it. The
writer (pkg/lineage/ledger.go) computes the chain head per org on append; the
async-ledger writer is single-goroutine, so per-org commit order is stable.
Legacy rows written before the chain shipped have record_hash = ''; the verifier counts and
skips them (they are not part of the chain) rather than treating them as
breakage.
Verification
yb lineage verify --pg-uri <url> --org <org-id> walks an org's rows in
timestamp order, recomputes each chained row's hash, and checks that
prev_hash matches the previous record_hash (NULL head). It reports the row
count, the chained/legacy split, and whether the chain is intact (and, if broken,
the offending row). It exits non-zero on a broken chain so it can be used as a
periodic compliance check.
WORM export
yb lineage export --pg-uri <url> --org <org-id> --out <file.jsonl> [--s3 <bucket> --region <r> --lock-compliance --lock-days <n>]
- Writes the org's rows as JSONL, one object per row, plus a
sha256checksum of the file. - With
--s3it uploads the archive and (with--lock-compliance --lock-days N) sets an S3 Object Lock in COMPLIANCE mode forNdays — nobody, including a root account, can alter or delete the export during the retention window. This is the "write once, read many" (WORM) evidence copy: you can keep the live DB mutable for operations while retaining an immutable, independently verifiable snapshot.
Verifying an export
An exported JSONL file is self-describing: recompute each row's record_hash
from its canonical_record + the previous row's record_hash and confirm the
sha256 file checksum matches. A compliant WORM export therefore doubles as an
offline audit artifact that does not require read access to the live database.