Core Concepts — Pipelines
Every type signature below is verbatim from proto/proto-k3/ (k3_source.proto, k3_pipeline.proto, k3_ingest.proto). Pillar packages are dodil.data.source.v1, dodil.data.pipeline.v1, dodil.data.ingest.v1. Wire encodings: gRPC follows the proto types directly; HTTP uses pbjson (camelCase, int64 as strings, enums as wire-name strings).
The six types: Source, Credential, Pipeline, Template, Rule, IngestJob. Their lifecycle and how they compose:
bucket ──┬── Source (where to read from)
│ │
│ └── Credential (how to auth)
│
├── Pipeline (Scriptum template + destination)
│ │
│ └── Template (catalog entry)
│
└── Rule (filter + pipeline binding)
│
└── IngestJob (one execution per matched object)Source
Where K3 reads objects from. Every bucket has an auto-created internal S3 source the moment you call CreateBucket — you don’t register it, name it, or authenticate it. Every direct upload to the bucket flows through your rules.
Preview — external sources (
SOURCE_PROVIDER_GOOGLE_DRIVE,_SHAREPOINT,_CONFLUENCE,_GITHUB) are available in the API but are still solidifying for production use. The internal S3 path is the production-ready channel today.
HTTP
{
"sourceId": "src_a1b2…",
"bucket": "kb-prod",
"provider": "SOURCE_PROVIDER_GOOGLE_DRIVE",
"name": "finance-drive",
"description": "Q2 finance docs",
"rootPath": "/Q2",
"providerAccount": "drive-id-123",
"syncIntervalSeconds": "3600",
"enabled": true,
"status": "SOURCE_STATUS_ACTIVE",
"totalObjects": "1247",
"indexedObjects": "1240",
"totalBytes": "8923471024",
"createdAt": "1716840000000",
"updatedAt": "1716843600000",
"lastSyncAt": "1716843600000",
"lastSuccessAt": "1716843600000"
}Key facts:
provider = SOURCE_PROVIDER_INTERNAL_S3is the auto-created per-bucket source. It fires on every direct S3 PUT to the bucket.sync_interval_seconds = 0means manual sync only — useful when you want explicit control viaTriggerDiscovery.provider_accountis provider-shaped: for Google Drive it’s a drive ID; for GitHub a repo URL; for InternalS3 the bucket name itself.total_objects/indexed_objects/total_bytesare aggregates the source maintains as it discovers and ingests.statusreflects the source’s most recent sync attempt —SOURCE_STATUS_ACTIVEis the steady-state success. A freshly auto-created internal source reportsSOURCE_STATUS_PENDING, because it has not run a sync yet. That is normal and does not stop direct uploads from firing rules; don’t gate your setup on seeingACTIVE.- The auto-created internal source is created with
name: "internal",description: "Auto-created internal S3 source",enabled: true,sync_interval_seconds: 300. Sourcehas full CRUD on the API —CreateSource,GetSource,ListSources,UpdateSource(PATCH /:bucket/sources/:source_id) andDeleteSource. The CLI exposes only the first three.
Credential
How K3 authenticates to an external source. Credentials are org-scoped and may optionally link to a source_id. The secret payload is a oneof keyed on credential_type.
Preview — credentials are used by external sources, which are Preview. The internal S3 source needs no credential. The shape below reflects the API as it stands today; expect refinements as external source support hardens.
HTTP
{
"info": {
"credentialId": "cred_a1b2…",
"sourceId": "src_a1b2…",
"provider": "SOURCE_PROVIDER_GOOGLE_DRIVE",
"credentialType": "CREDENTIAL_TYPE_OAUTH2",
"displayName": "gdrive-main",
"isPrimary": true,
"isValid": true,
"createdAt": "1716840000000",
"updatedAt": "1716843600000",
"expiresAt": "1716847200000"
},
"oauth2": {
"accessToken": "ya29.…",
"refreshToken": "1//…",
"expiresAt": "1716847200000",
"tokenType": "Bearer",
"scopes": ["https://www.googleapis.com/auth/drive.readonly"]
}
}The HTTP request body for StoreCredential follows the same oneof shape — exactly one of accessKey / oauth2 / apiKey / serviceAccount / pat is set.
Key facts:
- Secrets are stored in HashiCorp Vault behind K3.
ListCredentialsreturns onlyCredentialInfo— never a payload. GetCredentialis RETIRED, with no replacement. It returned raw access/refresh tokens, API keys, service-account JSON and PATs at viewer tier; a control plane whose job is to hold secrets in Vault must not offer an org-level RPC that reads them back in plaintext. There is no route and no RPC. To rotate an expired OAuth token, callRefreshOAuthToken(POST /admin/credentials/{credential_id}/refresh), which refreshes it in place.ValidateCredentialis RETIRED. It validated nothing — it echoed the storedis_valid, which is only ever writtentrue, and stampedvalidated_at: now(), implying a live provider check it never performed. A real probe would be a new RPC that actually calls the provider; none exists yet.- The surviving surface is exactly four RPCs:
StoreCredential,ListCredentials,DeleteCredential,RefreshOAuthToken, plusGetOAuthUrl/ExchangeOAuthCodefor the authorization-code flow. is_primaryis a per-(source_id, provider)flag — at most one primary per pairing. Used by sync to pick which credential to authenticate with.
Pipeline
The recipe: a Scriptum template name + per-pipeline options + an optional destination. First-class row in the K3 DB. Rules and any other triggers reference pipelines by pipeline_id.
HTTP
{
"pipelineId": "pipe_a1b2…",
"bucket": "kb-prod",
"name": "text_embedding_index",
"scriptumTemplate": "text_embedding_index",
"options": {
"chunk_size": "1000",
"chunk_overlap": "150"
},
"createdAt": "1716840000000",
"updatedAt": "1716843600000",
"recipeName": "document-rag",
"destination": {
"storeEntityId": "ent_a1b2…",
"facet": "PIPELINE_FACET_VECTOR",
"name": "docs-index",
"description": "",
"status": "DESTINATION_STATUS_ACTIVE",
"engineId": "eng_a1b2…",
"createdAt": "1716840000000",
"updatedAt": "1716840000000",
"vector": {
"dimensions": 768,
"distanceMetric": "DISTANCE_METRIC_COSINE",
"sparseMode": "SPARSE_MODE_NONE",
"embeddingType": "EMBEDDING_TYPE_FLOAT",
"modality": "text",
"embeddingSource": "EMBEDDING_SOURCE_PIPELINE",
"templateId": "text_embedding_index",
"embedModel": "jina-embeddings-v4",
"physicalName": "k3_1f2e3d4c…"
}
}
}Key facts:
store_entity_id/store_entity_kind/store_entity_nameno longer exist onPipeline— fields 6, 7 and 8 arereserved. Everything they said now lives on the nesteddestinationmessage:destination.storeEntityId,destination.facet,destination.name. Code reading the old flat fields gets nothing back.- A pipeline with no
destinationis a free pipeline — it runs the Scriptum script and persists nothing durable; you observe its output through the ingest job. PIPELINE_FACET_TABLEmaps to the stored string"warehouse". The column kept its historical value; the wire uses the current vocabulary. Expect"warehouse"wherever a kind string appears (IngestJob.pipeline_kind,PipelineBinding.pipeline_kind) andPIPELINE_FACET_TABLEwherever the enum does.- Filter
ListPipelineswithfacet(orfree_onlyfor the complement — the two are mutually exclusive and the server rejects them together). Route viapipeline_id. Pipeline.nameis not the name you typed for the facet creators.CreateVectorPipelineputs your name ondestination.nameand setsPipeline.nameto the template id, because the row it names is the index pipeline.dodil data vector collection listtherefore prints the same string under NAME and TEMPLATE.- Two
script_sourcemodes onCreatePipeline—scriptum_templateorspawn_from_template. They are now historical aliases for the same thing: both mean “this is the template id to dispatch”, since the worker builds per-call env at dispatch time. optionsis an overlay on top of the Scriptum script’s defaults. Validated server-side against the template’sScriptContract.CreatePipelinenever creates a destination. It is the non-creating creator: it makes a free pipeline, or binds to a destination that already exists (server validates it belongs to the same org + bucket). Creating a destination is the job of a facet creator —CreateVectorPipeline,CreateTablePipeline,CreateObjectPipeline. There is no graph creator.- Re-bind with
UpdatePipelineand a newstore_entity_id(empty string = make the pipeline free).UpdatePipeline.destinationedits the bound destination in place, but a destination’s facet is immutable — supplying a config variant that doesn’t match is rejected.
Creating a pipeline does NOT create an ingest rule
Worth its own heading because it is the single most common way a working-looking setup silently does nothing. Every facet creator’s proto comment says it explicitly — CreateTablePipelineRequest: “This handler does NOT auto-create an ingest rule — callers own rule scope, same as the vector facet.”
A pipeline with no Rule pointing at it never runs. Binding the rule is a separate, mandatory call. The one exception is dodil data recipe install, which is a client-side orchestrator that issues the pipeline call and the CreateRule call for you.
Template
A row in the Scriptum template catalog. Org-scoped, not bucket-scoped — served from GET /admin/templates, not a bucket path. K3 ships a production catalog of 34 templates across six categories: embedding (10), core (9), actions (7), vision (4), ecommerce (2), financials (2). See API Reference → Templates → The catalog for the full list with descriptions and modalities.
message Template {
string id = 1; // Scriptum template ID
string name = 2;
string description = 3;
repeated string tags = 4;
repeated string tools_required = 5;
map<string, string> labels = 6; // structured key-value labels
string category = 7; // "embedding" | "core" | "actions" | …
// Typed I/O contract — proxied verbatim from Scriptum's GetTemplate.
// UI form generators walk this; K3 reads it server-side to validate
// caller-supplied template inputs and to resolve schema-shaping facts.
dodil.data.common.v1.ScriptContract contract = 8;
// Modalities the template handles, from its `modality` label.
repeated string modalities = 9;
// File extensions it accepts, from its contract (lowercase, no dot).
// Drives an ingest rule's glob patterns.
repeated string accepted_extensions = 10;
repeated string accepted_content_types = 11;
// Object-facet templates only: "markdown" | "text" | "json" | "file".
string output_kind = 12;
}Key facts:
- The real category values are
actions,core,ecommerce,embedding,financials,vision. There is noextraction,analyticsorstructuringcategory — passing one to--categoryreturns nothing. --categoryand--searchfilter server-side;--labeldoes not.ListTemplatesRequest.labelsis accepted on the wire and then ignored —dodil data template list --label anything=at-allreturns the entire catalog unchanged. Filter labels client-side withjquntil that lands.- The
facetfilter onListTemplatesis the real per-pillar narrowing, and it is whatdodil data table templatesuses (PIPELINE_FACET_TABLE→ 17 templates).PipelineFacetreplaced the oldPillarenum, which said the same four things. accepted_extensions/accepted_content_typesare the modality gate. A table pipeline’s template must declare@accepts_extension—CreateTablePipelinerefuses one that doesn’t, because the derived rule globs come from it and a table pipeline with no accepted extensions can never fire.contractis a typed schema —ScriptContract, detailed in the next section. It tells you what inputs the template accepts and what shape its outputs take.- For full Scriptum semantics see the Scriptum docs (separate product) — K3 surfaces only the catalog.
ScriptContract
The typed I/O contract carried on Template.contract (every pillar’s Template surfaces it). It’s K3’s anti-corruption mirror of Scriptum’s contract — re-homed into dodil.data.common.v1 so pillar protos expose a template’s schema without importing scriptum.proto. Two consumers: form generators (UI / CLI) walk inputs to render per-field controls; the server validates caller-supplied inputs against inputs at creation time (pipeline options, CreateVectorPipeline.template_inputs, table-pipeline inputs).
message ScriptContract {
repeated TypedField inputs = 1; // what callers may set (validated server-side)
repeated TypedField outputs = 2; // shape of what the script emits
repeated TypedField stream = 3; // streamed / intermediate fields
repeated TypedField env = 4; // environment inputs (keys, model handles)
repeated EnumDef enums = 5; // named enums referenced by TypeRef.enum_name
repeated string accepted_content_types = 6; // input MIME globs (`image/*`); empty = no restriction
repeated string accepted_extensions = 7; // lowercase, no leading dot; empty = unrestricted
}
// One field in inputs / outputs / stream / env.
message TypedField {
string name = 1;
TypeRef type = 2;
string default_value_json = 3; // JSON literal: `"hi"`, `42`, `true`, `[1,2]`; empty = no default
bool required = 4; // required if `true` OR no default_value_json
string description = 5;
}
// A field's type — oneof over four kinds, plus constraints keyed to the kind.
message TypeRef {
oneof kind {
TypeRefPrimitive primitive = 1; // text|number|integer|boolean|object|binary|date|timestamp|any
string enum_name = 2; // names an entry in ScriptContract.enums
ListType list = 3; // list<T> — recursive
ObjectShape object = 4; // anonymous record of named TypedFields — recursive
}
NumericConstraints number_constraints = 10; // numeric kinds
StringConstraints string_constraints = 11; // text kind
ListConstraints list_constraints = 12; // list kind
}
message ListType { TypeRef item = 1; }
message ObjectShape { repeated TypedField fields = 1; }
// Constraints — only the message matching the field's kind is set; bounds optional (absent = unconstrained).
message NumericConstraints { optional double minimum = 1; optional double maximum = 2; bool exclusive_min = 3; bool exclusive_max = 4; optional double multiple_of = 5; }
message StringConstraints { optional uint32 min_length = 1; optional uint32 max_length = 2; optional string pattern = 3; } // pattern = ECMA-262 regex
message ListConstraints { optional uint32 min_items = 1; optional uint32 max_items = 2; bool unique_items = 3; }
// Enums — a field with kind=enum_name resolves to one of these; `base` sets wire serialization.
enum EnumBase { ENUM_BASE_UNSPECIFIED = 0; ENUM_BASE_TEXT = 1; ENUM_BASE_INTEGER = 2; ENUM_BASE_NUMBER = 3; } // string | int64 | double
message EnumDef { string name = 1; EnumBase base = 2; repeated EnumVariant variants = 3; }
message EnumVariant { oneof value { string value_text = 1; int64 value_integer = 2; double value_number = 3; } string description = 4; } // value matches EnumDef.baseWhat the proto doesn’t say outright:
listandobjectnest — aTypeRefrecurses to describelist<object{…}>and deeper.- Defaults are JSON-encoded strings, not native values — decode
default_value_jsonas a JSON literal before use. accepted_*is the modality gate — align an ingest rule’s MIME / extension filters with these, or the rule won’t match what the template can actually ingest.
Reading a contract from the CLI:
dodil data template get text_embedding_index -o json | jq '.contract.inputs'Rule
The trigger: which objects in which source should fire which pipeline. Pure binding — rules don’t own destinations; they reference a pipeline that does.
HTTP
{
"ruleId": "rule_a1b2…",
"bucket": "kb-prod",
"sourceId": "src_a1b2…",
"name": "pdf-contracts",
"description": "All PDFs under contracts/",
"includePatterns": ["contracts/**/*.pdf"],
"excludePatterns": ["contracts/**/draft-*"],
"includeMimeTypes": ["application/pdf"],
"excludeMimeTypes": [],
"minSizeBytes": "0",
"maxSizeBytes": "0",
"enabled": true,
"priority": 100,
"createdAt": "1716840000000",
"updatedAt": "1716843600000",
"pipelineId": "pipe_a1b2…",
"binding": {
"pipelineName": "embed-contracts",
"pipelineKind": "vector",
"destinationId": "ent_a1b2…",
"destinationKind": "vector",
"destinationName": "docs-index"
}
}Key facts:
- Matching is conjunctive across filter axes: an object must satisfy
include_patternsAND not matchexclude_patternsAND satisfy MIME include/exclude AND be within[min_size_bytes, max_size_bytes]. Each axis is OR’d within itself (any include pattern matching is enough). Excludes are checked before includes — if an exclude matches, K3 rejects the object without considering includes. priorityisint32, higher values evaluated first. Use it to express “specific rules before general ones.”- Multiple rules can match the same object — each match spawns its own
IngestJob. Useenabled = falseto pause a rule without deleting it. bindingis display-only — server-resolved frompipeline_id. Don’t make routing decisions from it; if you delete a pipeline, dependent rules still exist withpipeline_idpointing at nothing andbindingempty.
Glob pattern syntax
include_patterns and exclude_patterns use K3’s in-house glob matcher with these tokens:
| Token | Meaning | Example |
|---|---|---|
* | any chars except / (single path segment) | *.pdf matches report.pdf, NOT dir/report.pdf |
** | any chars including / (zero or more segments) | **/*.pdf matches both report.pdf AND a/b/report.pdf |
? | exactly one character | file?.txt matches file1.txt, NOT file12.txt |
| literal | match exactly | readme.md matches the key readme.md |
Behavior:
- Case-insensitive (ASCII).
**/*.pdfmatchesReport.PDF,IMG_1234.JPGmatches**/*.jpg,README.MDmatchesreadme.md. The matcher lowercases both pattern and key before comparing — same convention as other production glob implementations (globset, fast-glob, minimatch). Bucket keys + extensions are always ASCII so K3 uses cheap ASCII case-folding, not full Unicode. **matches zero or more path segments —**/test.txtmatches bothtest.txt(at the root) ANDa/b/c/test.txt(nested). Trailing/after**is optional.
Not supported (yet — work around with multiple patterns):
- POSIX character classes —
[a-z],[!a]don’t expand - Brace expansion —
{pdf,docx}is treated literally - Escaping — there’s no way to match a literal
*,?, or**in the object key itself
For either-or expansion, use multiple include patterns:
dodil data ingest add multi-ext -b kb-prod \
--source "$SOURCE_ID" --collection "$PIPELINE_ID" \
--include "**/*.pdf" --include "**/*.docx" --include "**/*.txt"MIME pattern syntax
include_mime_types and exclude_mime_types use a simpler matcher than globs:
| Pattern shape | Matches |
|---|---|
Exact MIME (text/plain) | only that exact MIME |
Subtype wildcard (text/*) | any MIME with that type — text/plain, text/html, text/csv, … |
Not supported: type wildcards (*/json doesn’t work), brace expansion (text/{plain,html}), or any other shape. List multiple exact / subtype-wildcard entries instead.
Evaluation order (worth knowing for debugging)
K3 evaluates a rule against an object in this exact order, short-circuiting on the first reject:
- exclude_patterns — if any match, reject (early-exit; cheap)
- include_patterns — if non-empty and none match, reject
- exclude_mime_types — if any match, reject
- include_mime_types — if non-empty and none match, reject
- max_size_bytes (when
> 0) — if object exceeds, reject - min_size_bytes (when
> 0) — if object is smaller, reject
If all axes pass, the object matches.
IngestJob
One execution of one pipeline against one object. The unit of observability for everything that happens after a rule matches.
HTTP
{
"jobId": "job_a1b2…",
"object": { "bucket": "kb-prod", "key": "contracts/acme-2026.pdf" },
"status": "INGEST_STATUS_COMPLETED",
"pipelineId": "pipe_a1b2…",
"pipelineName": "embed-contracts",
"pipelineKind": "vector",
"ruleId": "rule_a1b2…",
"chunksCreated": 47,
"embeddingsCreated": 47,
"rowsWritten": 0,
"createdAt": "1716843600000",
"updatedAt": "1716843680000",
"threadId": "thread_a1b2…",
"vectorStatus": "success",
"outputSize": 18429,
"batchesReceived": 5,
"embeddingsWritten": 47
}Key facts:
statustransitions are not always linear — transient failures retry automatically throughRETRYING → PROCESSING → COMPLETED, or land inFAILEDafter the final attempt.rule_idis absent for jobs spawned viaTriggerIngest(single-object manual trigger) without arule_idoverride.pipeline_kindcontrols which counters populate:vector→chunks_created/embeddings_created/embeddings_written;warehouse→rows_written;object→objects_written(0 or 1 in v1);free→ none.thread_idis the Scriptum thread ID — useful for cross-referencing with Scriptum logs / observability.vector_statusis a fine-grained sub-status for vector pipelines specifically: did the embedding-persist phase succeed (success), fail (failed), or skip because the script produced no embeddings (skipped)?batches_receivedvsembeddings_writtenlets you spot partial writes: Scriptum yielded N batches but only M embeddings landed in the collection.
When ingest runs
Three triggers can cause a pipeline to run against an object — all produce the same IngestJob shape, only rule_id differs:
| Trigger | When | rule_id on job |
|---|---|---|
| Direct upload | Successful S3 PUT (or CompleteMultipartUpload) on the bucket’s byte plane | Set — every rule that matches the object fires |
| Source sync | TriggerDiscovery (manual) or the source’s sync_interval_seconds tick | Set — same matching logic |
| One-shot trigger | TriggerIngest on a single ObjectRef | Optional — pass rule_id / pipeline_id explicitly or let server resolve |
Reliability:
- Transient failures retry automatically via NATS JetStream redelivery (the
INGEST_STATUS_RETRYINGstate). Two budgets share one cap: ordinary transient failures stop at the logical failure-retry budget (K3_INGEST_FAILURE_RETRY_BUDGET, default 5), while a job parked on a still-provisioning engine may use the full hard redelivery cap (K3_INGEST_MAX_DELIVER, default 30) so it outlasts a slow spin-up. - After the effective maximum the job becomes
INGEST_STATUS_FAILEDwith the last error inerror/error_details. Only retryable errors are redelivered at all — a permanent failure goes straight toFAILEDon the first attempt. - Three replay scopes, coarsest to finest:
TriggerIngestionon a source (retry_failed = true) — bulk, every failed/partial object on that source.RetryIngestJob(POST /:bucket/ingest/jobs/{job_id}/retry) — re-runs one existing job in place: samejob_id, no new row, per-attempt state reset. OnlyFAILEDandPARTIALjobs qualify; anything else returnsFAILED_PRECONDITION.TriggerIngeston a single object — spawns a new job, and is the only path that accepts a per-eventoptionsoverlay.
See Recipes → Replay & retry for the full triage loop.
See also
- Quickstart — wire all six entities together in 5 minutes
- API Reference — gRPC + HTTP for every Source / Pipeline / Ingest RPC
- CLI Guide —
dodil data source·pipeline·template·ingest·credential - Vector — destination kind for
vectorpipelines - Tables — destination kind for
warehousepipelines - Conventions — auth headers, error envelope, wire encoding rules