Skip to content

Ingest pipeline

The pipeline is the only writer of knowledge. Ingest commits raw evidence first; extraction and governance follow.

What commits together

An ingest request writes four things in one database transaction:

flowchart LR
    subgraph TX["one transaction — all or nothing"]
        A[Raw message row]
        B[Hash-chain audit entry]
        C[Durable idempotency record]
        D[Oban extraction job]
    end
    REQ[POST /api/v1/ingest] --> TX
    TX --> RESP[202 with message id and accepted status]

All four commit or roll back together, preventing observations without audit entries and jobs without observations. Oban shares PostgreSQL, so job insertion participates in the transaction.

What happens after the response

Extraction always runs after the response in the durable ingest job lane:

sequenceDiagram
    autonumber
    participant J as Extraction job
    participant G as Model gateway
    participant S as Structured generator
    participant GOV as Governance engine
    participant IDX as Index and projection work

    J->>G: extraction request (ingest_extractor role)
    G->>S: provider output
    S->>S: validate against Ash-derived schema
    alt output does not fit the schema
        S->>G: bounded repair attempt
    end
    S-->>J: candidate statements
    loop each candidate
        J->>GOV: proposal (target level, sensitivity, confidence)
        GOV-->>J: lifecycle state + decision record
    end
    J->>IDX: embed, index, mark projections dirty

The model call holds no database connection

Extraction touches the database in two short bursts with the model call in between, never in one long transaction:

flowchart LR
    R["read the message<br/>(short transaction)"] --> M["call the model<br/>(no transaction)"]
    M --> W["write the knowledge<br/>(short transaction)"]

Model calls may take minutes and up to two repair attempts. Keeping them outside transactions avoids exhausting the connection pool. The message is marked extracted only after knowledge commits, so interrupted work retries and concurrent extraction remains serialized. Usage records survive a later write failure.

A background job names its own Account

Every background job declares the Account from its queue row before accessing Account-owned data. Without that transaction-local Account, row-level security returns no rows: ingest could return 202 while extraction finds nothing. This is automatic and has no operator setting.

Structured extraction, not free text

Candidates must match schemas derived from their Ash resources. Invalid output gets bounded repair attempts, then rejection.

Extraction also does four things a naive extractor gets wrong:

  • Refuses unreadable text. A model can collapse into repeated ellipsis or invisible padding. Such a statement is rejected: a durable claim must carry letters or digits, and above a short length most of its characters must. The model is asked to rewrite it; if it cannot, the observation waits for retry.
  • Resolves subject independently of source. Who a statement is about is decided on its own, not assumed to be the speaker.
  • Discounts hearsay. "Dana said the deadline moved" is weaker evidence about the deadline than Dana saying it.
  • Records complete provenance. Provider, model, version, prompt, and pipeline identity travel with the result.

Replay is safe

Deterministic idempotency keys make replay merge provenance instead of creating duplicate statements. The reconciler finds durable records whose jobs never ran. Account administrators can enqueue it independently with POST /api/v1/operations/reconcile.

A provider outage delays freshness; it does not lose data

If the model provider is down, the durable observation remains and the job retries. Production never silently falls back to the deterministic test adapter.

The job lanes

Background work is split into named Oban queues, each with its own concurrency limit:

Queue Concurrency What runs there
ingest 10 Message and document extraction — the user-facing lane
dream 2 Background reasoning over already-governed knowledge
lifecycle 2 Revalidation and expiry sweeps
projection 2 Context, scope, and session projection rebuilds; entity resolution
governance 2 Validation continuations and answer correlation
connector 2 External connector polling and sync
portability 1 Rebuild work after a logical archive import
reconciler 1 Durable records whose job never ran

Portability and reconciliation are serialised to one at a time because each walks an entire Account.

CARTULARY_INGEST_QUEUE_LIMIT changes the ingest limit at boot. It must be paired with a CARTULARY_MODEL_STREAM_POOL_SIZE at least as large as the expected concurrent hosted model calls. Keep the stream-pool count at 1: Finch chooses among multiple shards randomly, so one shard with enough capacity does not create an avoidable queue behind a busy shard. For 100 parallel ingestion flows on one node, set the queue to 100 and the stream-pool size to 128, then confirm the provider and database can sustain that load.

Background jobs run through Ash actions with authorisation on, exactly like an HTTP caller. A job is not a privilege-escalation path.

Dream-time

The dream lane consolidates duplicates, resolves entities, schedules revalidation, and prepares validation questions. It is throttled first when token budgets tighten and never bypasses governance.

What never enters audit metadata or job arguments

Audit entries and Oban arguments may carry ids, states, levels, channels, flags, counts, and content hashes. They never carry statement text, messages, document bytes, extracted text, prompts, answers, connector cursors, or secrets. Erasure therefore removes content while retaining decision evidence.